diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index a107c1ac3a..9dd57a5ec8 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -4,7 +4,7 @@ FROM ${BASEIMAGE} # Flutter SDK # https://flutter.dev/docs/development/tools/sdk/releases?tab=linux ENV FLUTTER_CHANNEL="stable" -ENV FLUTTER_VERSION="3.24.5" +ENV FLUTTER_VERSION="3.29.1" ENV FLUTTER_HOME=/flutter ENV PATH=${PATH}:${FLUTTER_HOME}/bin diff --git a/.github/DISCUSSION_TEMPLATE/feature-request.yaml b/.github/DISCUSSION_TEMPLATE/feature-request.yaml index 9aeee8004c..7a260188ea 100644 --- a/.github/DISCUSSION_TEMPLATE/feature-request.yaml +++ b/.github/DISCUSSION_TEMPLATE/feature-request.yaml @@ -11,7 +11,7 @@ body: - type: checkboxes attributes: - label: I have searched the existing feature requests to make sure this is not a duplicate request. + label: I have searched the existing feature requests, both open and closed, to make sure this is not a duplicate request. options: - label: "Yes" required: true diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index c7519a4684..acbb7c785b 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1 @@ -custom: ['https://buy.immich.app'] +custom: ['https://buy.immich.app', 'https://immich.store'] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 346c6e60f2..c4e1cc2bf1 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -1,6 +1,13 @@ name: Report an issue with Immich description: Report an issue with Immich body: + - type: checkboxes + attributes: + label: I have searched the existing issues, both open and closed, to make sure this is not a duplicate report. + options: + - label: "Yes" + required: true + - type: markdown attributes: value: | diff --git a/.github/PULL_REQUEST_TEMPLATE/config.yml b/.github/PULL_REQUEST_TEMPLATE/config.yml index 6663b04cbc..4172e3df95 100644 --- a/.github/PULL_REQUEST_TEMPLATE/config.yml +++ b/.github/PULL_REQUEST_TEMPLATE/config.yml @@ -1,2 +1 @@ -blank_issues_enabled: false blank_pull_request_template_enabled: false diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md deleted file mode 100644 index 83a365eab9..0000000000 --- a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md +++ /dev/null @@ -1,22 +0,0 @@ -## Description - - - - -Fixes # (issue) - - -## How Has This Been Tested? - - - -- [ ] Test A -- [ ] Test B - -## Screenshots (if appropriate): - - -## Checklist: - -- [ ] I have performed a self-review of my own code -- [ ] I have made corresponding changes to the documentation if applicable \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..aa756a7d08 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,36 @@ +## Description + + + + + +Fixes # (issue) + +## How Has This Been Tested? + + + +- [ ] Test A +- [ ] Test B + +

Screenshots (if appropriate)

+ + + +
+ + + +## Checklist: + +- [ ] I have performed a self-review of my own code +- [ ] I have made corresponding changes to the documentation if applicable +- [ ] I have no unrelated changes in the PR. +- [ ] I have confirmed that any new dependencies are strictly necessary. +- [ ] I have written tests for new code (if applicable) +- [ ] I have followed naming conventions/patterns in the surrounding code +- [ ] All code in `src/services/` uses repositories implementations for database calls, filesystem operations, etc. +- [ ] All code in `src/repositories/` is pretty basic/simple and does not have any immich specific logic (that belongs in `src/services/`) diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index c12b6e607a..6e3597b2f1 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -29,9 +29,11 @@ jobs: filters: | mobile: - 'mobile/**' + workflow: + - '.github/workflows/build-mobile.yml' - name: Check if we should force jobs to run id: should_force - run: echo "should_force=${{ github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch' }}" >> "$GITHUB_OUTPUT" + run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch' }}" >> "$GITHUB_OUTPUT" build-sign-android: name: Build and sign Android diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index e7effc8551..1243a81105 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -56,10 +56,10 @@ jobs: uses: actions/checkout@v4 - name: Set up QEMU - uses: docker/setup-qemu-action@v3.3.0 + uses: docker/setup-qemu-action@v3.6.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.8.0 + uses: docker/setup-buildx-action@v3.10.0 - name: Login to GitHub Container Registry uses: docker/login-action@v3 @@ -88,7 +88,7 @@ jobs: type=raw,value=latest,enable=${{ github.event_name == 'release' }} - name: Build and push image - uses: docker/build-push-action@v6.12.0 + uses: docker/build-push-action@v6.15.0 with: file: cli/Dockerfile platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/docker-cleanup.yml b/.github/workflows/docker-cleanup.yml deleted file mode 100644 index 29b518e0a5..0000000000 --- a/.github/workflows/docker-cleanup.yml +++ /dev/null @@ -1,73 +0,0 @@ -# This workflow runs on certain conditions to check for and potentially -# delete container images from the GHCR which no longer have an associated -# code branch. -# Requires a PAT with the correct scope set in the secrets. -# -# This workflow will not trigger runs on forked repos. - -name: Docker Cleanup - -on: - pull_request: - types: - - "closed" - push: - paths: - - ".github/workflows/docker-cleanup.yml" - -concurrency: - group: registry-tags-cleanup - cancel-in-progress: false - -jobs: - cleanup-images: - name: Cleanup Stale Images Tags for ${{ matrix.primary-name }} - runs-on: ubuntu-24.04 - strategy: - fail-fast: false - matrix: - include: - - primary-name: "immich-server" - - primary-name: "immich-machine-learning" - env: - # Requires a personal access token with the OAuth scope delete:packages - TOKEN: ${{ secrets.PACKAGE_DELETE_TOKEN }} - steps: - - name: Clean temporary images - if: "${{ env.TOKEN != '' }}" - uses: stumpylog/image-cleaner-action/ephemeral@v0.9.0 - with: - token: "${{ env.TOKEN }}" - owner: "immich-app" - is_org: "true" - do_delete: "true" - package_name: "${{ matrix.primary-name }}" - scheme: "pull_request" - repo_name: "immich" - match_regex: '^pr-(\d+)$|^(\d+)$' - - cleanup-untagged-images: - name: Cleanup Untagged Images Tags for ${{ matrix.primary-name }} - runs-on: ubuntu-24.04 - needs: - - cleanup-images - strategy: - fail-fast: false - matrix: - include: - - primary-name: "immich-server" - - primary-name: "immich-machine-learning" - - primary-name: "immich-build-cache" - env: - # Requires a personal access token with the OAuth scope delete:packages - TOKEN: ${{ secrets.PACKAGE_DELETE_TOKEN }} - steps: - - name: Clean untagged images - if: "${{ env.TOKEN != '' }}" - uses: stumpylog/image-cleaner-action/untagged@v0.9.0 - with: - token: "${{ env.TOKEN }}" - owner: "immich-app" - do_delete: "true" - is_org: "true" - package_name: "${{ matrix.primary-name }}" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index d9672ff371..5d19e5b90f 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -5,7 +5,6 @@ on: push: branches: [main] pull_request: - branches: [main] release: types: [published] @@ -36,10 +35,12 @@ jobs: - 'i18n/**' machine-learning: - 'machine-learning/**' + workflow: + - '.github/workflows/docker.yml' - name: Check if we should force jobs to run id: should_force - run: echo "should_force=${{ github.event_name == 'workflow_dispatch' || github.event_name == 'release' }}" >> "$GITHUB_OUTPUT" + run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' }}" >> "$GITHUB_OUTPUT" retag_ml: name: Re-Tag ML @@ -61,8 +62,10 @@ jobs: REGISTRY_NAME="ghcr.io" REPOSITORY=${{ github.repository_owner }}/immich-machine-learning TAG_OLD=main${{ matrix.suffix }} - TAG_NEW=${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }} - docker buildx imagetools create -t $REGISTRY_NAME/$REPOSITORY:$TAG_NEW $REGISTRY_NAME/$REPOSITORY:$TAG_OLD + 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 }} + 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 retag_server: name: Re-Tag Server @@ -84,36 +87,46 @@ jobs: REGISTRY_NAME="ghcr.io" REPOSITORY=${{ github.repository_owner }}/immich-server TAG_OLD=main${{ matrix.suffix }} - TAG_NEW=${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }} - docker buildx imagetools create -t $REGISTRY_NAME/$REPOSITORY:$TAG_NEW $REGISTRY_NAME/$REPOSITORY:$TAG_OLD - + 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 }} + 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 build_and_push_ml: name: Build and Push ML needs: pre-job if: ${{ needs.pre-job.outputs.should_run_ml == 'true' }} - runs-on: ubuntu-latest + runs-on: ${{ matrix.runner }} env: image: immich-machine-learning context: machine-learning file: machine-learning/Dockerfile + GHCR_REPO: ghcr.io/${{ github.repository_owner }}/immich-machine-learning strategy: # Prevent a failure in one image from stopping the other builds fail-fast: false matrix: include: - - platforms: linux/amd64,linux/arm64 + - platform: linux/amd64 + runner: ubuntu-latest device: cpu - - platforms: linux/amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + device: cpu + + - platform: linux/amd64 + runner: ubuntu-latest device: cuda suffix: -cuda - - platforms: linux/amd64 + - platform: linux/amd64 + runner: ubuntu-latest device: openvino suffix: -openvino - - platforms: linux/arm64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm device: armnn suffix: -armnn - platforms: linux/arm64 @@ -121,73 +134,56 @@ jobs: suffix: -rknn steps: + - name: Prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + - name: Checkout uses: actions/checkout@v4 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3.3.0 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.8.0 - - - name: Login to Docker Hub - # Only push to Docker Hub when making a release - if: ${{ github.event_name == 'release' }} - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + uses: docker/setup-buildx-action@v3.10.0 - name: Login to GitHub Container Registry uses: docker/login-action@v3 - # Skip when PR from a fork if: ${{ !github.event.pull_request.head.repo.fork }} with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Generate docker image tags - id: metadata - uses: docker/metadata-action@v5 - with: - flavor: | - # Disable latest tag - latest=false - images: | - name=ghcr.io/${{ github.repository_owner }}/${{env.image}} - name=altran1502/${{env.image}},enable=${{ github.event_name == 'release' }} - tags: | - # Tag with branch name - type=ref,event=branch,suffix=${{ matrix.suffix }} - # Tag with pr-number - type=ref,event=pr,suffix=${{ matrix.suffix }} - # Tag with git tag on release - type=ref,event=tag,suffix=${{ matrix.suffix }} - type=raw,value=release,enable=${{ github.event_name == 'release' }},suffix=${{ matrix.suffix }} - - - name: Determine build cache output - id: cache-target + - name: Generate cache key suffix run: | if [[ "${{ github.event_name }}" == "pull_request" ]]; then - # Essentially just ignore the cache output (PR can't write to registry cache) + echo "CACHE_KEY_SUFFIX=pr-${{ github.event.number }}" >> $GITHUB_ENV + else + echo "CACHE_KEY_SUFFIX=$(echo ${{ github.ref_name }} | sed 's/[^a-zA-Z0-9]/-/g')" >> $GITHUB_ENV + fi + + - name: Generate cache target + id: cache-target + run: | + if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then + # Essentially just ignore the cache output (forks can't write to registry cache) echo "cache-to=type=local,dest=/tmp/discard,ignore-error=true" >> $GITHUB_OUTPUT else - echo "cache-to=type=registry,mode=max,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{ env.image }}" >> $GITHUB_OUTPUT + echo "cache-to=type=registry,ref=${{ env.GHCR_REPO }}-build-cache:${{ env.PLATFORM_PAIR }}-${{ matrix.device }}-${{ env.CACHE_KEY_SUFFIX }},mode=max,compression=zstd" >> $GITHUB_OUTPUT fi - name: Build and push image - uses: docker/build-push-action@v6.12.0 + id: build + uses: docker/build-push-action@v6.15.0 with: context: ${{ env.context }} file: ${{ env.file }} platforms: ${{ matrix.platforms }} - # Skip pushing when PR from a fork - push: ${{ !github.event.pull_request.head.repo.fork }} - cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{env.image}} - cache-to: ${{ steps.cache-target.outputs.cache-to }} - tags: ${{ steps.metadata.outputs.tags }} labels: ${{ steps.metadata.outputs.labels }} + cache-to: ${{ steps.cache-target.outputs.cache-to }} + cache-from: | + type=registry,ref=${{ env.GHCR_REPO }}-build-cache:${{ env.PLATFORM_PAIR }}-${{ matrix.device }}-${{ env.CACHE_KEY_SUFFIX }} + type=registry,ref=${{ env.GHCR_REPO }}-build-cache:${{ env.PLATFORM_PAIR }}-${{ matrix.device }}-main + outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=${{ !github.event.pull_request.head.repo.fork }} build-args: | DEVICE=${{ matrix.device }} BUILD_ID=${{ github.run_id }} @@ -195,100 +191,251 @@ jobs: BUILD_SOURCE_REF=${{ github.ref_name }} BUILD_SOURCE_COMMIT=${{ github.sha }} + - name: Export digest + run: | + mkdir -p ${{ runner.temp }}/digests + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: ml-digests-${{ matrix.device }}-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + merge_ml: + name: Merge & Push ML + runs-on: ubuntu-latest + if: ${{ needs.pre-job.outputs.should_run_ml == 'true' && !github.event.pull_request.head.repo.fork }} + env: + GHCR_REPO: ghcr.io/${{ github.repository_owner }}/immich-machine-learning + DOCKER_REPO: altran1502/immich-machine-learning + strategy: + matrix: + include: + - device: cpu + - device: cuda + suffix: -cuda + - device: openvino + suffix: -openvino + - device: armnn + suffix: -armnn + needs: + - build_and_push_ml + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: ml-digests-${{ matrix.device }}-* + merge-multiple: true + + - name: Login to Docker Hub + if: ${{ github.event_name == 'release' }} + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Generate docker image tags + id: meta + uses: docker/metadata-action@v5 + env: + DOCKER_METADATA_PR_HEAD_SHA: "true" + with: + flavor: | + # Disable latest tag + latest=false + suffix=${{ matrix.suffix }} + images: | + name=${{ env.GHCR_REPO }} + name=${{ env.DOCKER_REPO }},enable=${{ github.event_name == 'release' }} + tags: | + # Tag with branch name + type=ref,event=branch + # Tag with pr-number + type=ref,event=pr + # Tag with long commit sha hash + type=sha,format=long,prefix=commit- + # Tag with git tag on release + type=ref,event=tag + type=raw,value=release,enable=${{ github.event_name == 'release' }} + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.GHCR_REPO }}@sha256:%s ' *) build_and_push_server: name: Build and Push Server - runs-on: ubuntu-latest + runs-on: ${{ matrix.runner }} needs: pre-job if: ${{ needs.pre-job.outputs.should_run_server == 'true' }} env: image: immich-server context: . file: server/Dockerfile + GHCR_REPO: ghcr.io/${{ github.repository_owner }}/immich-server strategy: fail-fast: false matrix: include: - - platforms: linux/amd64,linux/arm64 - device: cpu + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm steps: + - name: Prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + - name: Checkout uses: actions/checkout@v4 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3.3.0 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.8.0 - - - name: Login to Docker Hub - # Only push to Docker Hub when making a release - if: ${{ github.event_name == 'release' }} - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + uses: docker/setup-buildx-action@v3 - name: Login to GitHub Container Registry uses: docker/login-action@v3 - # Skip when PR from a fork if: ${{ !github.event.pull_request.head.repo.fork }} with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Generate docker image tags - id: metadata - uses: docker/metadata-action@v5 - with: - flavor: | - # Disable latest tag - latest=false - images: | - name=ghcr.io/${{ github.repository_owner }}/${{env.image}} - name=altran1502/${{env.image}},enable=${{ github.event_name == 'release' }} - tags: | - # Tag with branch name - type=ref,event=branch,suffix=${{ matrix.suffix }} - # Tag with pr-number - type=ref,event=pr,suffix=${{ matrix.suffix }} - # Tag with git tag on release - type=ref,event=tag,suffix=${{ matrix.suffix }} - type=raw,value=release,enable=${{ github.event_name == 'release' }},suffix=${{ matrix.suffix }} - - - name: Determine build cache output - id: cache-target + - name: Generate cache key suffix run: | if [[ "${{ github.event_name }}" == "pull_request" ]]; then - # Essentially just ignore the cache output (PR can't write to registry cache) + echo "CACHE_KEY_SUFFIX=pr-${{ github.event.number }}" >> $GITHUB_ENV + else + echo "CACHE_KEY_SUFFIX=$(echo ${{ github.ref_name }} | sed 's/[^a-zA-Z0-9]/-/g')" >> $GITHUB_ENV + fi + + - name: Generate cache target + id: cache-target + run: | + if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then + # Essentially just ignore the cache output (forks can't write to registry cache) echo "cache-to=type=local,dest=/tmp/discard,ignore-error=true" >> $GITHUB_OUTPUT else - echo "cache-to=type=registry,mode=max,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{ env.image }}" >> $GITHUB_OUTPUT + echo "cache-to=type=registry,ref=${{ env.GHCR_REPO }}-build-cache:${{ env.PLATFORM_PAIR }}-${{ matrix.device }}-${{ env.CACHE_KEY_SUFFIX }},mode=max,compression=zstd" >> $GITHUB_OUTPUT fi - name: Build and push image - uses: docker/build-push-action@v6.12.0 + id: build + uses: docker/build-push-action@v6.15.0 with: context: ${{ env.context }} file: ${{ env.file }} - platforms: ${{ matrix.platforms }} - # Skip pushing when PR from a fork - push: ${{ !github.event.pull_request.head.repo.fork }} - cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{env.image}} - cache-to: ${{ steps.cache-target.outputs.cache-to }} - tags: ${{ steps.metadata.outputs.tags }} + platforms: ${{ matrix.platform }} labels: ${{ steps.metadata.outputs.labels }} + cache-to: ${{ steps.cache-target.outputs.cache-to }} + cache-from: | + type=registry,ref=${{ env.GHCR_REPO }}-build-cache:${{ env.PLATFORM_PAIR }}-${{ env.CACHE_KEY_SUFFIX }} + type=registry,ref=${{ env.GHCR_REPO }}-build-cache:${{ env.PLATFORM_PAIR }}-main + outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=${{ !github.event.pull_request.head.repo.fork }} build-args: | - DEVICE=${{ matrix.device }} + DEVICE=cpu BUILD_ID=${{ github.run_id }} BUILD_IMAGE=${{ github.event_name == 'release' && github.ref_name || steps.metadata.outputs.tags }} BUILD_SOURCE_REF=${{ github.ref_name }} BUILD_SOURCE_COMMIT=${{ github.sha }} + - name: Export digest + run: | + mkdir -p ${{ runner.temp }}/digests + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: server-digests-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + merge_server: + name: Merge & Push Server + runs-on: ubuntu-latest + if: ${{ needs.pre-job.outputs.should_run_server == 'true' && !github.event.pull_request.head.repo.fork }} + env: + GHCR_REPO: ghcr.io/${{ github.repository_owner }}/immich-server + DOCKER_REPO: altran1502/immich-server + needs: + - build_and_push_server + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: server-digests-* + merge-multiple: true + + - name: Login to Docker Hub + if: ${{ github.event_name == 'release' }} + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Generate docker image tags + id: meta + uses: docker/metadata-action@v5 + env: + DOCKER_METADATA_PR_HEAD_SHA: "true" + with: + flavor: | + # Disable latest tag + latest=false + suffix=${{ matrix.suffix }} + images: | + name=${{ env.GHCR_REPO }} + name=${{ env.DOCKER_REPO }},enable=${{ github.event_name == 'release' }} + tags: | + # Tag with branch name + type=ref,event=branch + # Tag with pr-number + type=ref,event=pr + # Tag with long commit sha hash + type=sha,format=long,prefix=commit- + # Tag with git tag on release + type=ref,event=tag + type=raw,value=release,enable=${{ github.event_name == 'release' }} + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.GHCR_REPO }}@sha256:%s ' *) + success-check-server: name: Docker Build & Push Server Success - needs: [build_and_push_server, retag_server] + needs: [merge_server, retag_server] runs-on: ubuntu-latest if: always() steps: @@ -301,7 +448,7 @@ jobs: success-check-ml: name: Docker Build & Push ML Success - needs: [build_and_push_ml, retag_ml] + needs: [merge_ml, retag_ml] runs-on: ubuntu-latest if: always() steps: diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index efb84d510e..63b906748f 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -15,7 +15,7 @@ jobs: pre-job: runs-on: ubuntu-latest outputs: - should_run: ${{ steps.found_paths.outputs.docs == 'true' || steps.should_force.outputs.should_force == 'true' }} + should_run: ${{ steps.found_paths.outputs.docs == 'true' || steps.should_force.outputs.should_force == 'true' }} steps: - name: Checkout code uses: actions/checkout@v4 @@ -25,9 +25,11 @@ jobs: filters: | docs: - 'docs/**' + workflow: + - '.github/workflows/docs-build.yml' - name: Check if we should force jobs to run id: should_force - run: echo "should_force=${{ github.event_name == 'release' || github.ref_name == 'main' }}" >> "$GITHUB_OUTPUT" + run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'release' || github.ref_name == 'main' }}" >> "$GITHUB_OUTPUT" build: name: Docs Build diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 9be52f90f0..df4856b1a1 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -41,8 +41,8 @@ jobs: with: token: ${{ steps.generate-token.outputs.token }} - - name: Install Poetry - run: pipx install poetry + - name: Install uv + uses: astral-sh/setup-uv@v5 - name: Bump version run: misc/release/pump-version.sh -s "${{ inputs.serverBump }}" -m "${{ inputs.mobileBump }}" @@ -74,7 +74,7 @@ jobs: with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - + - name: Checkout uses: actions/checkout@v4 with: diff --git a/.github/workflows/preview-label.yaml b/.github/workflows/preview-label.yaml new file mode 100644 index 0000000000..1c324ab49f --- /dev/null +++ b/.github/workflows/preview-label.yaml @@ -0,0 +1,33 @@ +name: Preview label + +on: + pull_request: + types: [labeled, closed] + +jobs: + comment-status: + runs-on: ubuntu-latest + if: ${{ github.event.action == 'labeled' && github.event.label.name == 'preview' }} + permissions: + pull-requests: write + steps: + - uses: mshick/add-pr-comment@v2 + with: + message-id: "preview-status" + message: "Deploying preview environment to https://pr-${{ github.event.pull_request.number }}.preview.internal.immich.cloud/" + + remove-label: + runs-on: ubuntu-latest + if: ${{ github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'preview') }} + permissions: + pull-requests: write + steps: + - uses: actions/github-script@v7 + with: + script: | + github.rest.issues.removeLabel({ + issue_number: context.payload.pull_request.number, + owner: context.repo.owner, + repo: context.repo.repo, + name: 'preview' + }) diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index 196f8faf59..1e2020a19d 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -23,9 +23,11 @@ jobs: filters: | mobile: - 'mobile/**' + workflow: + - '.github/workflows/static_analysis.yml' - name: Check if we should force jobs to run id: should_force - run: echo "should_force=${{ github.event_name == 'release' }}" >> "$GITHUB_OUTPUT" + run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'release' }}" >> "$GITHUB_OUTPUT" mobile-dart-analyze: name: Run Dart Code Analysis @@ -48,6 +50,26 @@ jobs: run: dart pub get working-directory: ./mobile + - name: Run Build Runner + run: make build + working-directory: ./mobile + + - name: Find file changes + uses: tj-actions/verify-changed-files@v20 + id: verify-changed-files + with: + files: | + mobile/**/*.g.dart + mobile/**/*.gr.dart + mobile/**/*.drift.dart + + - name: Verify files have not changed + if: steps.verify-changed-files.outputs.files_changed == 'true' + run: | + echo "ERROR: Generated files not up to date! Run make_build inside the mobile directory" + echo "Changed files: ${{ steps.verify-changed-files.outputs.changed_files }}" + exit 1 + - name: Run dart analyze run: dart analyze --fatal-infos working-directory: ./mobile @@ -59,8 +81,3 @@ jobs: - name: Run dart custom_lint run: dart run custom_lint working-directory: ./mobile - - # Enable after riverpod generator migration is completed - # - name: Run dart custom lint - # run: dart run custom_lint - # working-directory: ./mobile diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5b2bbd1ab1..99f41697d4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,10 +43,12 @@ jobs: - 'mobile/**' machine-learning: - 'machine-learning/**' + workflow: + - '.github/workflows/test.yml' - name: Check if we should force jobs to run id: should_force - run: echo "should_force=${{ github.event_name == 'workflow_dispatch' }}" >> "$GITHUB_OUTPUT" + run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'workflow_dispatch' }}" >> "$GITHUB_OUTPUT" server-unit-tests: name: Test & Lint Server @@ -244,25 +246,30 @@ jobs: run: npm run check if: ${{ !cancelled() }} - medium-tests-server: + server-medium-tests: name: Medium Tests (Server) needs: pre-job if: ${{ needs.pre-job.outputs.should_run_server == 'true' }} - runs-on: mich + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./server steps: - name: Checkout code uses: actions/checkout@v4 - with: - submodules: 'recursive' - - name: Production build - if: ${{ !cancelled() }} - run: docker compose -f e2e/docker-compose.yml build + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: './server/.nvmrc' + + - name: Run npm install + run: npm ci - name: Run medium tests + run: npm run test:medium if: ${{ !cancelled() }} - run: make test-medium e2e-tests-server-cli: name: End-to-End Tests (Server & CLI) @@ -373,27 +380,28 @@ jobs: working-directory: ./machine-learning steps: - uses: actions/checkout@v4 - - name: Install poetry - run: pipx install poetry + - name: Install uv + uses: astral-sh/setup-uv@v5 - uses: actions/setup-python@v5 - with: - python-version: 3.11 - cache: 'poetry' + # TODO: add caching when supported (https://github.com/actions/setup-python/pull/818) + # with: + # python-version: 3.11 + # cache: 'uv' - name: Install dependencies run: | - poetry install --with dev --with cpu + uv sync --extra cpu - name: Lint with ruff run: | - poetry run ruff check --output-format=github app export + uv run ruff check --output-format=github app export - name: Check black formatting run: | - poetry run black --check app export + uv run black --check app export - name: Run mypy type checking run: | - mkdir .mypy_cache && poetry run mypy --install-types --non-interactive --strict app/ + uv run mypy --strict app/ - name: Run tests and coverage run: | - poetry run pytest app --cov=app --cov-report term-missing + uv run pytest app --cov=app --cov-report term-missing shellcheck: name: ShellCheck @@ -450,7 +458,7 @@ jobs: runs-on: ubuntu-latest services: postgres: - image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 + image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52 env: POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres @@ -502,6 +510,7 @@ jobs: run: | echo "ERROR: Generated migration files not up to date!" echo "Changed files: ${{ steps.verify-changed-files.outputs.changed_files }}" + cat ./src/migrations/*-TestMigration.ts exit 1 - name: Run SQL generation diff --git a/.github/workflows/weblate-lock.yml b/.github/workflows/weblate-lock.yml new file mode 100644 index 0000000000..4189e51919 --- /dev/null +++ b/.github/workflows/weblate-lock.yml @@ -0,0 +1,57 @@ +name: Weblate checks + +on: + pull_request: + branches: [main] + +jobs: + pre-job: + runs-on: ubuntu-latest + outputs: + should_run: ${{ steps.found_paths.outputs.i18n == 'true' && github.head_ref != 'chore/translations'}} + steps: + - name: Checkout code + uses: actions/checkout@v4 + - id: found_paths + uses: dorny/paths-filter@v3 + with: + filters: | + i18n: + - 'i18n/!(en)**\.json' + - name: Debug + run: | + echo "Should run: ${{ steps.found_paths.outputs.i18n == 'true' && github.head_ref != 'chore/translations'}}" + echo "Found i18n paths: ${{ steps.found_paths.outputs.i18n }}" + echo "Head ref: ${{ github.head_ref }}" + + enforce-lock: + name: Check Weblate Lock + needs: [ pre-job ] + runs-on: ubuntu-latest + if: ${{ needs.pre-job.outputs.should_run == 'true' }} + steps: + - name: Check weblate lock + run: | + if [[ "false" = $(curl https://hosted.weblate.org/api/components/immich/immich/lock/ | jq .locked) ]]; then + exit 1 + fi + - name: Find Pull Request + uses: juliangruber/find-pull-request-action@v1 + id: find-pr + with: + branch: chore/translations + - name: Fail if existing weblate PR + if: ${{ steps.find-pr.outputs.number }} + run: exit 1 + success-check-lock: + name: Weblate Lock Check Success + needs: [ enforce-lock ] + runs-on: ubuntu-latest + if: always() + steps: + - name: Any jobs failed? + if: ${{ contains(needs.*.result, 'failure') }} + run: exit 1 + - name: All jobs passed or skipped + if: ${{ !(contains(needs.*.result, 'failure')) }} + run: echo "All jobs passed or skipped" && echo "${{ toJSON(needs.*.result) }}" diff --git a/README.md b/README.md index 7b037ba1e7..fea9801d41 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@

-
+
License: AGPLv3 Discord -
-
+
+

@@ -63,7 +63,7 @@ Access the demo [here](https://demo.immich.app). The demo is running on a Free-tier Oracle VM in Amsterdam with a 2.4Ghz quad-core ARM64 CPU and 24GB RAM. -For the mobile app, you can use `https://demo.immich.app/api` for the `Server Endpoint URL` +For the mobile app, you can use `https://demo.immich.app` for the `Server Endpoint URL` ### Login credentials diff --git a/cli/.nvmrc b/cli/.nvmrc index d5b283a3ac..7d41c735d7 100644 --- a/cli/.nvmrc +++ b/cli/.nvmrc @@ -1 +1 @@ -22.13.1 +22.14.0 diff --git a/cli/Dockerfile b/cli/Dockerfile index 6ddceafb59..356537213b 100644 --- a/cli/Dockerfile +++ b/cli/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22.13.1-alpine3.20@sha256:c52e20859a92b3eccbd3a36c5e1a90adc20617d8d421d65e8a622e87b5dac963 AS core +FROM node:22.14.0-alpine3.20@sha256:40be979442621049f40b1d51a26b55e281246b5de4e5f51a18da7beb6e17e3f9 AS core WORKDIR /usr/src/open-api/typescript-sdk COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./ diff --git a/cli/package-lock.json b/cli/package-lock.json index f7364aaa21..623a8128db 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,17 +1,19 @@ { "name": "@immich/cli", - "version": "2.2.42", + "version": "2.2.53", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.42", + "version": "2.2.53", "license": "GNU Affero General Public License version 3", "dependencies": { + "chokidar": "^4.0.3", "fast-glob": "^3.3.2", "fastq": "^1.17.1", - "lodash-es": "^4.17.21" + "lodash-es": "^4.17.21", + "micromatch": "^4.0.8" }, "bin": { "immich": "dist/index.js" @@ -23,8 +25,9 @@ "@types/byte-size": "^8.1.0", "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", + "@types/micromatch": "^4.0.9", "@types/mock-fs": "^4.13.1", - "@types/node": "^22.10.9", + "@types/node": "^22.13.9", "@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/parser": "^8.15.0", "@vitest/coverage-v8": "^3.0.0", @@ -35,7 +38,7 @@ "eslint-config-prettier": "^10.0.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^56.0.1", - "globals": "^15.9.0", + "globals": "^16.0.0", "mock-fs": "^5.2.0", "prettier": "^3.2.5", "prettier-plugin-organize-imports": "^4.0.0", @@ -52,14 +55,14 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.125.1", + "version": "1.129.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.10.9", + "@types/node": "^22.13.9", "typescript": "^5.3.3" } }, @@ -318,9 +321,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", - "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", + "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", "cpu": [ "ppc64" ], @@ -335,9 +338,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", - "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", + "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", "cpu": [ "arm" ], @@ -352,9 +355,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", - "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", + "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", "cpu": [ "arm64" ], @@ -369,9 +372,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", - "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", + "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", "cpu": [ "x64" ], @@ -386,9 +389,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", - "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", + "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", "cpu": [ "arm64" ], @@ -403,9 +406,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", - "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", + "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", "cpu": [ "x64" ], @@ -420,9 +423,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", - "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", + "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", "cpu": [ "arm64" ], @@ -437,9 +440,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", - "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", + "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", "cpu": [ "x64" ], @@ -454,9 +457,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", - "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", + "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", "cpu": [ "arm" ], @@ -471,9 +474,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", - "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", + "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", "cpu": [ "arm64" ], @@ -488,9 +491,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", - "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", + "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", "cpu": [ "ia32" ], @@ -505,9 +508,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", - "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", + "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", "cpu": [ "loong64" ], @@ -522,9 +525,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", - "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", + "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", "cpu": [ "mips64el" ], @@ -539,9 +542,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", - "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", + "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", "cpu": [ "ppc64" ], @@ -556,9 +559,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", - "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", + "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", "cpu": [ "riscv64" ], @@ -573,9 +576,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", - "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", + "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", "cpu": [ "s390x" ], @@ -590,9 +593,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", - "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", + "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", "cpu": [ "x64" ], @@ -607,9 +610,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", - "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", + "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", "cpu": [ "arm64" ], @@ -624,9 +627,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", - "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", + "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", "cpu": [ "x64" ], @@ -641,9 +644,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", - "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", + "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", "cpu": [ "arm64" ], @@ -658,9 +661,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", - "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", + "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", "cpu": [ "x64" ], @@ -675,9 +678,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", - "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", + "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", "cpu": [ "x64" ], @@ -692,9 +695,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", - "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", + "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", "cpu": [ "arm64" ], @@ -709,9 +712,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", - "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", + "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", "cpu": [ "ia32" ], @@ -726,9 +729,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", - "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", + "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", "cpu": [ "x64" ], @@ -768,13 +771,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.1.tgz", - "integrity": "sha512-fo6Mtm5mWyKjA/Chy1BYTdn5mGJoDNjC7C64ug20ADsRDGrA85bN3uK3MaKbeRkRuuIEAR5N33Jr1pbm411/PA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", + "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.5", + "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -807,9 +810,9 @@ } }, "node_modules/@eslint/core": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.10.0.tgz", - "integrity": "sha512-gFHJ+xBOo4G3WRlR1e/3G8A6/KZAH6zcE/hkLRCZTi/B9avAG365QhFA8uOGzTMqgTghpn7/fSnscW++dpMSAw==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", + "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -820,9 +823,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz", - "integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.0.tgz", + "integrity": "sha512-yaVPAiNAalnCZedKLdR21GOGILMLKPyqSLWaAjQFvYA2i/ciDi8ArYVr69Anohb6cH2Ukhqti4aFnYyPm8wdwQ==", "dev": true, "license": "MIT", "dependencies": { @@ -881,9 +884,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.18.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.18.0.tgz", - "integrity": "sha512-fK6L7rxcq6/z+AaQMtiFTkvbHkBLNlwyRxHpKawP0x3u9+NC6MQTnFW+AdpwC6gfHTW0051cokQgtTN2FqlxQA==", + "version": "9.21.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.21.0.tgz", + "integrity": "sha512-BqStZ3HX8Yz6LvsF5ByXYrtigrV5AXADWLAGc7PH/1SxOb7/FIYYMszZZWiUou/GB9P2lXWk2SV4d+Z8h0nknw==", "dev": true, "license": "MIT", "engines": { @@ -891,9 +894,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.5.tgz", - "integrity": "sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -901,13 +904,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.5.tgz", - "integrity": "sha512-lB05FkqEdUg2AA0xEbUz0SnkXT1LcCTa438W4IWTUh4hdOnVbQyOJ81OrDXsJk/LSiJHubgGEFoR5EHq1NsH1A==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.7.tgz", + "integrity": "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.10.0", + "@eslint/core": "^0.12.0", "levn": "^0.4.1" }, "engines": { @@ -966,9 +969,9 @@ } }, "node_modules/@humanwhocodes/retry": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz", - "integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", + "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1427,6 +1430,13 @@ "win32" ] }, + "node_modules/@types/braces": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/braces/-/braces-3.0.4.tgz", + "integrity": "sha512-0WR3b8eaISjEW7RpZnclONaLFDf7buaowRHdqLp4vLj54AsSAYWfh3DRbfiYJY9XDxMgx1B4sE1Afw2PGpuHOA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/byte-size": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/@types/byte-size/-/byte-size-8.1.2.tgz", @@ -1472,6 +1482,16 @@ "@types/lodash": "*" } }, + "node_modules/@types/micromatch": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.9.tgz", + "integrity": "sha512-7V+8ncr22h4UoYRLnLXSpTxjQrNUXtWHGeMPRJt1nULXI57G9bIcpyrHlmrQ7QK24EyyuXvYcSSWAM8GA9nqCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/braces": "*" + } + }, "node_modules/@types/mock-fs": { "version": "4.13.4", "resolved": "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.4.tgz", @@ -1482,9 +1502,9 @@ } }, "node_modules/@types/node": { - "version": "22.10.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.9.tgz", - "integrity": "sha512-Ir6hwgsKyNESl/gLOcEz3krR4CBGgliDqBQ2ma4wIhEx0w+xnoeTq3tdrNw15kU3SxogDjOgv9sqdtLW8mIHaw==", + "version": "22.13.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", + "integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==", "dev": true, "license": "MIT", "dependencies": { @@ -1498,21 +1518,21 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.20.0.tgz", - "integrity": "sha512-naduuphVw5StFfqp4Gq4WhIBE2gN1GEmMUExpJYknZJdRnc+2gDzB8Z3+5+/Kv33hPQRDGzQO/0opHE72lZZ6A==", + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.25.0.tgz", + "integrity": "sha512-VM7bpzAe7JO/BFf40pIT1lJqS/z1F8OaSsUB3rpFJucQA4cOSuH2RVVVkFULN+En0Djgr29/jb4EQnedUo95KA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/type-utils": "8.20.0", - "@typescript-eslint/utils": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", + "@typescript-eslint/scope-manager": "8.25.0", + "@typescript-eslint/type-utils": "8.25.0", + "@typescript-eslint/utils": "8.25.0", + "@typescript-eslint/visitor-keys": "8.25.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1528,16 +1548,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.20.0.tgz", - "integrity": "sha512-gKXG7A5HMyjDIedBi6bUrDcun8GIjnI8qOwVLiY3rx6T/sHP/19XLJOnIq/FgQvWLHja5JN/LSE7eklNBr612g==", + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.25.0.tgz", + "integrity": "sha512-4gbs64bnbSzu4FpgMiQ1A+D+urxkoJk/kqlDJ2W//5SygaEiAP2B4GoS7TEdxgwol2el03gckFV9lJ4QOMiiHg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/typescript-estree": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", + "@typescript-eslint/scope-manager": "8.25.0", + "@typescript-eslint/types": "8.25.0", + "@typescript-eslint/typescript-estree": "8.25.0", + "@typescript-eslint/visitor-keys": "8.25.0", "debug": "^4.3.4" }, "engines": { @@ -1553,14 +1573,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.20.0.tgz", - "integrity": "sha512-J7+VkpeGzhOt3FeG1+SzhiMj9NzGD/M6KoGn9f4dbz3YzK9hvbhVTmLj/HiTp9DazIzJ8B4XcM80LrR9Dm1rJw==", + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.25.0.tgz", + "integrity": "sha512-6PPeiKIGbgStEyt4NNXa2ru5pMzQ8OYKO1hX1z53HMomrmiSB+R5FmChgQAP1ro8jMtNawz+TRQo/cSXrauTpg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0" + "@typescript-eslint/types": "8.25.0", + "@typescript-eslint/visitor-keys": "8.25.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1571,16 +1591,16 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.20.0.tgz", - "integrity": "sha512-bPC+j71GGvA7rVNAHAtOjbVXbLN5PkwqMvy1cwGeaxUoRQXVuKCebRoLzm+IPW/NtFFpstn1ummSIasD5t60GA==", + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.25.0.tgz", + "integrity": "sha512-d77dHgHWnxmXOPJuDWO4FDWADmGQkN5+tt6SFRZz/RtCWl4pHgFl3+WdYCn16+3teG09DY6XtEpf3gGD0a186g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.20.0", - "@typescript-eslint/utils": "8.20.0", + "@typescript-eslint/typescript-estree": "8.25.0", + "@typescript-eslint/utils": "8.25.0", "debug": "^4.3.4", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1595,9 +1615,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.20.0.tgz", - "integrity": "sha512-cqaMiY72CkP+2xZRrFt3ExRBu0WmVitN/rYPZErA80mHjHx/Svgp8yfbzkJmDoQ/whcytOPO9/IZXnOc+wigRA==", + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.25.0.tgz", + "integrity": "sha512-+vUe0Zb4tkNgznQwicsvLUJgZIRs6ITeWSCclX1q85pR1iOiaj+4uZJIUp//Z27QWu5Cseiw3O3AR8hVpax7Aw==", "dev": true, "license": "MIT", "engines": { @@ -1609,20 +1629,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.20.0.tgz", - "integrity": "sha512-Y7ncuy78bJqHI35NwzWol8E0X7XkRVS4K4P4TCyzWkOJih5NDvtoRDW4Ba9YJJoB2igm9yXDdYI/+fkiiAxPzA==", + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.25.0.tgz", + "integrity": "sha512-ZPaiAKEZ6Blt/TPAx5Ot0EIB/yGtLI2EsGoY6F7XKklfMxYQyvtL+gT/UCqkMzO0BVFHLDlzvFqQzurYahxv9Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", + "@typescript-eslint/types": "8.25.0", + "@typescript-eslint/visitor-keys": "8.25.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1636,16 +1656,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.20.0.tgz", - "integrity": "sha512-dq70RUw6UK9ei7vxc4KQtBRk7qkHZv447OUZ6RPQMQl71I3NZxQJX/f32Smr+iqWrB02pHKn2yAdHBb0KNrRMA==", + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.25.0.tgz", + "integrity": "sha512-syqRbrEv0J1wywiLsK60XzHnQe/kRViI3zwFALrNEgnntn1l24Ra2KvOAWwWbWZ1lBZxZljPDGOq967dsl6fkA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/typescript-estree": "8.20.0" + "@typescript-eslint/scope-manager": "8.25.0", + "@typescript-eslint/types": "8.25.0", + "@typescript-eslint/typescript-estree": "8.25.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1660,13 +1680,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.20.0.tgz", - "integrity": "sha512-v/BpkeeYAsPkKCkR8BDwcno0llhzWVqPOamQrAEMdpZav2Y9OVjd9dwJyBLJWwf335B5DmlifECIkZRJCaGaHA==", + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.25.0.tgz", + "integrity": "sha512-kCYXKAum9CecGVHGij7muybDfTS2sD3t0L4bJsEZLkyrXUImiCTq1M3LG2SRtOhiHFwMR9wAFplpT6XHYjTkwQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.20.0", + "@typescript-eslint/types": "8.25.0", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -1691,9 +1711,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.3.tgz", - "integrity": "sha512-uVbJ/xhImdNtzPnLyxCZJMTeTIYdgcC2nWtBBBpR1H6z0w8m7D+9/zrDIx2nNxgMg9r+X8+RY2qVpUDeW2b3nw==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.7.tgz", + "integrity": "sha512-Av8WgBJLTrfLOer0uy3CxjlVuWK4CzcLBndW1Nm2vI+3hZ2ozHututkfc7Blu1u6waeQ7J8gzPK/AsBRnWA5mQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1714,8 +1734,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.0.3", - "vitest": "3.0.3" + "@vitest/browser": "3.0.7", + "vitest": "3.0.7" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1724,15 +1744,15 @@ } }, "node_modules/@vitest/expect": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.3.tgz", - "integrity": "sha512-SbRCHU4qr91xguu+dH3RUdI5dC86zm8aZWydbp961aIR7G8OYNN6ZiayFuf9WAngRbFOfdrLHCGgXTj3GtoMRQ==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.7.tgz", + "integrity": "sha512-QP25f+YJhzPfHrHfYHtvRn+uvkCFCqFtW9CktfBxmB+25QqWsx7VB2As6f4GmwllHLDhXNHvqedwhvMmSnNmjw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.0.3", - "@vitest/utils": "3.0.3", - "chai": "^5.1.2", + "@vitest/spy": "3.0.7", + "@vitest/utils": "3.0.7", + "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, "funding": { @@ -1740,13 +1760,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.3.tgz", - "integrity": "sha512-XT2XBc4AN9UdaxJAeIlcSZ0ILi/GzmG5G8XSly4gaiqIvPV3HMTSIDZWJVX6QRJ0PX1m+W8Cy0K9ByXNb/bPIA==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.7.tgz", + "integrity": "sha512-qui+3BLz9Eonx4EAuR/i+QlCX6AUZ35taDQgwGkK/Tw6/WgwodSrjN1X2xf69IA/643ZX5zNKIn2svvtZDrs4w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.0.3", + "@vitest/spy": "3.0.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -1767,9 +1787,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.3.tgz", - "integrity": "sha512-gCrM9F7STYdsDoNjGgYXKPq4SkSxwwIU5nkaQvdUxiQ0EcNlez+PdKOVIsUJvh9P9IeIFmjn4IIREWblOBpP2Q==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.7.tgz", + "integrity": "sha512-CiRY0BViD/V8uwuEzz9Yapyao+M9M008/9oMOSQydwbwb+CMokEq3XVaF3XK/VWaOK0Jm9z7ENhybg70Gtxsmg==", "dev": true, "license": "MIT", "dependencies": { @@ -1780,38 +1800,38 @@ } }, "node_modules/@vitest/runner": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.3.tgz", - "integrity": "sha512-Rgi2kOAk5ZxWZlwPguRJFOBmWs6uvvyAAR9k3MvjRvYrG7xYvKChZcmnnpJCS98311CBDMqsW9MzzRFsj2gX3g==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.7.tgz", + "integrity": "sha512-WeEl38Z0S2ZcuRTeyYqaZtm4e26tq6ZFqh5y8YD9YxfWuu0OFiGFUbnxNynwLjNRHPsXyee2M9tV7YxOTPZl2g==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.0.3", - "pathe": "^2.0.1" + "@vitest/utils": "3.0.7", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.3.tgz", - "integrity": "sha512-kNRcHlI4txBGztuJfPEJ68VezlPAXLRT1u5UCx219TU3kOG2DplNxhWLwDf2h6emwmTPogzLnGVwP6epDaJN6Q==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.7.tgz", + "integrity": "sha512-eqTUryJWQN0Rtf5yqCGTQWsCFOQe4eNz5Twsu21xYEcnFJtMU5XvmG0vgebhdLlrHQTSq5p8vWHJIeJQV8ovsA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.0.3", + "@vitest/pretty-format": "3.0.7", "magic-string": "^0.30.17", - "pathe": "^2.0.1" + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/spy": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.3.tgz", - "integrity": "sha512-7/dgux8ZBbF7lEIKNnEqQlyRaER9nkAL9eTmdKJkDO3hS8p59ATGwKOCUDHcBLKr7h/oi/6hP+7djQk8049T2A==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.7.tgz", + "integrity": "sha512-4T4WcsibB0B6hrKdAZTM37ekuyFZt2cGbEGd2+L0P8ov15J1/HUsUaqkXEQPNAWr4BtPPe1gI+FYfMHhEKfR8w==", "dev": true, "license": "MIT", "dependencies": { @@ -1822,14 +1842,14 @@ } }, "node_modules/@vitest/utils": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.3.tgz", - "integrity": "sha512-f+s8CvyzPtMFY1eZKkIHGhPsQgYo5qCm6O8KZoim9qm1/jT64qBgGpO5tHscNH6BzRHM+edLNOP+3vO8+8pE/A==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.7.tgz", + "integrity": "sha512-xePVpCRfooFX3rANQjwoditoXgWb1MaFbzmGuPP59MK6i13mrnDw/yEIyJudLeW6/38mCNcwCiJIGmpDPibAIg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.0.3", - "loupe": "^3.1.2", + "@vitest/pretty-format": "3.0.7", + "loupe": "^3.1.3", "tinyrainbow": "^2.0.0" }, "funding": { @@ -2047,9 +2067,9 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz", - "integrity": "sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", + "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", "dev": true, "license": "MIT", "dependencies": { @@ -2089,6 +2109,21 @@ "node": ">= 16" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/ci-info": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.0.0.tgz", @@ -2271,9 +2306,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", - "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", + "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2284,31 +2319,31 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.24.2", - "@esbuild/android-arm": "0.24.2", - "@esbuild/android-arm64": "0.24.2", - "@esbuild/android-x64": "0.24.2", - "@esbuild/darwin-arm64": "0.24.2", - "@esbuild/darwin-x64": "0.24.2", - "@esbuild/freebsd-arm64": "0.24.2", - "@esbuild/freebsd-x64": "0.24.2", - "@esbuild/linux-arm": "0.24.2", - "@esbuild/linux-arm64": "0.24.2", - "@esbuild/linux-ia32": "0.24.2", - "@esbuild/linux-loong64": "0.24.2", - "@esbuild/linux-mips64el": "0.24.2", - "@esbuild/linux-ppc64": "0.24.2", - "@esbuild/linux-riscv64": "0.24.2", - "@esbuild/linux-s390x": "0.24.2", - "@esbuild/linux-x64": "0.24.2", - "@esbuild/netbsd-arm64": "0.24.2", - "@esbuild/netbsd-x64": "0.24.2", - "@esbuild/openbsd-arm64": "0.24.2", - "@esbuild/openbsd-x64": "0.24.2", - "@esbuild/sunos-x64": "0.24.2", - "@esbuild/win32-arm64": "0.24.2", - "@esbuild/win32-ia32": "0.24.2", - "@esbuild/win32-x64": "0.24.2" + "@esbuild/aix-ppc64": "0.25.0", + "@esbuild/android-arm": "0.25.0", + "@esbuild/android-arm64": "0.25.0", + "@esbuild/android-x64": "0.25.0", + "@esbuild/darwin-arm64": "0.25.0", + "@esbuild/darwin-x64": "0.25.0", + "@esbuild/freebsd-arm64": "0.25.0", + "@esbuild/freebsd-x64": "0.25.0", + "@esbuild/linux-arm": "0.25.0", + "@esbuild/linux-arm64": "0.25.0", + "@esbuild/linux-ia32": "0.25.0", + "@esbuild/linux-loong64": "0.25.0", + "@esbuild/linux-mips64el": "0.25.0", + "@esbuild/linux-ppc64": "0.25.0", + "@esbuild/linux-riscv64": "0.25.0", + "@esbuild/linux-s390x": "0.25.0", + "@esbuild/linux-x64": "0.25.0", + "@esbuild/netbsd-arm64": "0.25.0", + "@esbuild/netbsd-x64": "0.25.0", + "@esbuild/openbsd-arm64": "0.25.0", + "@esbuild/openbsd-x64": "0.25.0", + "@esbuild/sunos-x64": "0.25.0", + "@esbuild/win32-arm64": "0.25.0", + "@esbuild/win32-ia32": "0.25.0", + "@esbuild/win32-x64": "0.25.0" } }, "node_modules/escalade": { @@ -2334,22 +2369,22 @@ } }, "node_modules/eslint": { - "version": "9.18.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.18.0.tgz", - "integrity": "sha512-+waTfRWQlSbpt3KWE+CjrPPYnbq9kfZIYUqapc0uBXyjTp8aYXZDsUH16m39Ryq3NjAVP4tjuF7KaukeqoCoaA==", + "version": "9.21.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.21.0.tgz", + "integrity": "sha512-KjeihdFqTPhOMXTt7StsDxriV4n66ueuF/jfPNC3j/lduHwr/ijDwJMsF+wyMJethgiKi5wniIE243vi07d3pg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.19.0", - "@eslint/core": "^0.10.0", - "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "9.18.0", - "@eslint/plugin-kit": "^0.2.5", + "@eslint/config-array": "^0.19.2", + "@eslint/core": "^0.12.0", + "@eslint/eslintrc": "^3.3.0", + "@eslint/js": "9.21.0", + "@eslint/plugin-kit": "^0.2.7", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.1", + "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", @@ -2394,9 +2429,9 @@ } }, "node_modules/eslint-config-prettier": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.0.1.tgz", - "integrity": "sha512-lZBts941cyJyeaooiKxAtzoPHTN+GbQTJFAIdQbRhA4/8whaAraEh47Whw/ZFfrjNSnlAxqfm9i0XVAEkULjCw==", + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.0.2.tgz", + "integrity": "sha512-1105/17ZIMjmCOJOPNfVdbXafLCLj3hPmkmB7dLgt7XsQ/zkxSuDerE/xgO3RxoHysR1N1whmquY0lSn2O0VLg==", "dev": true, "license": "MIT", "bin": { @@ -2407,9 +2442,9 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.2.tgz", - "integrity": "sha512-1yI3/hf35wmlq66C8yOyrujQnel+v5l1Vop5Cl2I6ylyNTT1JbuUUnV3/41PzwTzcyDp/oF0jWE3HXvcH5AQOQ==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.3.tgz", + "integrity": "sha512-qJ+y0FfCp/mQYQ/vWQ3s7eUlFEL4PyKfAJxsnYTJ4YT73nsJBWqmEpFryxV9OeUiqmsTsYJ5Y+KDNaeP31wrRw==", "dev": true, "license": "MIT", "dependencies": { @@ -2471,6 +2506,19 @@ "eslint": ">=8.56.0" } }, + "node_modules/eslint-plugin-unicorn/node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint-scope": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz", @@ -2685,9 +2733,9 @@ "dev": true }, "node_modules/fastq": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", - "integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==", + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -2828,9 +2876,9 @@ } }, "node_modules/globals": { - "version": "15.14.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.14.0.tgz", - "integrity": "sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==", + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.0.0.tgz", + "integrity": "sha512-iInW14XItCXET01CQFqudPOWP2jYMl7T+QRQT+UNcR/iQncN/F0UNpgd76iFkBPgNQb4+X3LV9tLJYzwh+Gl3A==", "dev": true, "license": "MIT", "engines": { @@ -3180,9 +3228,9 @@ "dev": true }, "node_modules/loupe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", - "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", + "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", "dev": true, "license": "MIT" }, @@ -3285,9 +3333,9 @@ } }, "node_modules/mock-fs": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.4.1.tgz", - "integrity": "sha512-sz/Q8K1gXXXHR+qr0GZg2ysxCRr323kuN10O7CtQjraJsFDJ4SJ+0I5MzALz7aRp9lHk8Cc/YdsT95h9Ka1aFw==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.5.0.tgz", + "integrity": "sha512-d/P1M/RacgM3dB0sJ8rjeRNXxtapkPCUnMGmIN0ixJ16F/E4GUZCvWcSGfWGz8eaXYvn1s9baUwNjI4LOPEjiA==", "dev": true, "license": "MIT", "engines": { @@ -3487,9 +3535,9 @@ } }, "node_modules/pathe": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.2.tgz", - "integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, @@ -3531,9 +3579,9 @@ } }, "node_modules/postcss": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz", - "integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==", + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", "dev": true, "funding": [ { @@ -3569,9 +3617,9 @@ } }, "node_modules/prettier": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", - "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.2.tgz", + "integrity": "sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg==", "dev": true, "license": "MIT", "bin": { @@ -3743,6 +3791,19 @@ "node": ">=8" } }, + "node_modules/readdirp": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", + "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "license": "MIT", + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/regexp-tree": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", @@ -4166,9 +4227,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.0.tgz", - "integrity": "sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz", + "integrity": "sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==", "dev": true, "license": "MIT", "engines": { @@ -4288,15 +4349,15 @@ } }, "node_modules/vite": { - "version": "6.0.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.11.tgz", - "integrity": "sha512-4VL9mQPKoHy4+FE0NnRE/kbY51TOfaknxAjt3fJbGJxhIpBZiqVzlZDEesWWsuREXHwNdAoOFZ9MkPEVXczHwg==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz", + "integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.24.2", - "postcss": "^8.4.49", - "rollup": "^4.23.0" + "esbuild": "^0.25.0", + "postcss": "^8.5.3", + "rollup": "^4.30.1" }, "bin": { "vite": "bin/vite.js" @@ -4360,16 +4421,16 @@ } }, "node_modules/vite-node": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.3.tgz", - "integrity": "sha512-0sQcwhwAEw/UJGojbhOrnq3HtiZ3tC7BzpAa0lx3QaTX0S3YX70iGcik25UBdB96pmdwjyY2uyKNYruxCDmiEg==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.7.tgz", + "integrity": "sha512-2fX0QwX4GkkkpULXdT1Pf4q0tC1i1lFOyseKoonavXUNlQ77KpW2XqBGGNIm/J4Ows4KxgGJzDguYVPKwG/n5A==", "dev": true, "license": "MIT", "dependencies": { "cac": "^6.7.14", "debug": "^4.4.0", "es-module-lexer": "^1.6.0", - "pathe": "^2.0.1", + "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0" }, "bin": { @@ -4403,31 +4464,31 @@ } }, "node_modules/vitest": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.3.tgz", - "integrity": "sha512-dWdwTFUW9rcnL0LyF2F+IfvNQWB0w9DERySCk8VMG75F8k25C7LsZoh6XfCjPvcR8Nb+Lqi9JKr6vnzH7HSrpQ==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.7.tgz", + "integrity": "sha512-IP7gPK3LS3Fvn44x30X1dM9vtawm0aesAa2yBIZ9vQf+qB69NXC5776+Qmcr7ohUXIQuLhk7xQR0aSUIDPqavg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "3.0.3", - "@vitest/mocker": "3.0.3", - "@vitest/pretty-format": "^3.0.3", - "@vitest/runner": "3.0.3", - "@vitest/snapshot": "3.0.3", - "@vitest/spy": "3.0.3", - "@vitest/utils": "3.0.3", - "chai": "^5.1.2", + "@vitest/expect": "3.0.7", + "@vitest/mocker": "3.0.7", + "@vitest/pretty-format": "^3.0.7", + "@vitest/runner": "3.0.7", + "@vitest/snapshot": "3.0.7", + "@vitest/spy": "3.0.7", + "@vitest/utils": "3.0.7", + "chai": "^5.2.0", "debug": "^4.4.0", "expect-type": "^1.1.0", "magic-string": "^0.30.17", - "pathe": "^2.0.1", + "pathe": "^2.0.3", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.0.3", + "vite-node": "3.0.7", "why-is-node-running": "^2.3.0" }, "bin": { @@ -4441,9 +4502,10 @@ }, "peerDependencies": { "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.0.3", - "@vitest/ui": "3.0.3", + "@vitest/browser": "3.0.7", + "@vitest/ui": "3.0.7", "happy-dom": "*", "jsdom": "*" }, @@ -4451,6 +4513,9 @@ "@edge-runtime/vm": { "optional": true }, + "@types/debug": { + "optional": true + }, "@types/node": { "optional": true }, @@ -4469,9 +4534,9 @@ } }, "node_modules/vitest-fetch-mock": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/vitest-fetch-mock/-/vitest-fetch-mock-0.4.3.tgz", - "integrity": "sha512-PhuEh+9HCsXFMRPUJilDL7yVDFufoxqk7ze+CNks64UGlfFXaJTn1bLABiNlEc0u25RERXQGj0Tm+M9i6UY9HQ==", + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/vitest-fetch-mock/-/vitest-fetch-mock-0.4.4.tgz", + "integrity": "sha512-i2RNEAKBgnLWwj5DVz8ouzaHaPVg1xaYgAUmU5p+baJ149upnO+yJLPchAiY9ij8hf0PDkJVVke1pftBxmT05g==", "dev": true, "license": "MIT", "engines": { diff --git a/cli/package.json b/cli/package.json index b995d852de..334bcc0b0c 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.42", + "version": "2.2.53", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", @@ -19,8 +19,9 @@ "@types/byte-size": "^8.1.0", "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", + "@types/micromatch": "^4.0.9", "@types/mock-fs": "^4.13.1", - "@types/node": "^22.10.9", + "@types/node": "^22.13.9", "@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/parser": "^8.15.0", "@vitest/coverage-v8": "^3.0.0", @@ -31,7 +32,7 @@ "eslint-config-prettier": "^10.0.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^56.0.1", - "globals": "^15.9.0", + "globals": "^16.0.0", "mock-fs": "^5.2.0", "prettier": "^3.2.5", "prettier-plugin-organize-imports": "^4.0.0", @@ -62,11 +63,13 @@ "node": ">=20.0.0" }, "dependencies": { + "chokidar": "^4.0.3", "fast-glob": "^3.3.2", "fastq": "^1.17.1", - "lodash-es": "^4.17.21" + "lodash-es": "^4.17.21", + "micromatch": "^4.0.8" }, "volta": { - "node": "22.13.1" + "node": "22.14.0" } } diff --git a/cli/src/commands/asset.spec.ts b/cli/src/commands/asset.spec.ts index 4bac1d00ab..21137a3296 100644 --- a/cli/src/commands/asset.spec.ts +++ b/cli/src/commands/asset.spec.ts @@ -1,12 +1,13 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { describe, expect, it, vi } from 'vitest'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { describe, expect, it, MockedFunction, vi } from 'vitest'; -import { Action, checkBulkUpload, defaults, Reason } from '@immich/sdk'; +import { Action, checkBulkUpload, defaults, getSupportedMediaTypes, Reason } from '@immich/sdk'; import createFetchMock from 'vitest-fetch-mock'; -import { checkForDuplicates, getAlbumName, uploadFiles, UploadOptionsDto } from './asset'; +import { checkForDuplicates, getAlbumName, startWatch, uploadFiles, UploadOptionsDto } from 'src/commands/asset'; vi.mock('@immich/sdk'); @@ -199,3 +200,112 @@ describe('checkForDuplicates', () => { }); }); }); + +describe('startWatch', () => { + let testFolder: string; + let checkBulkUploadMocked: MockedFunction; + + beforeEach(async () => { + vi.restoreAllMocks(); + + vi.mocked(getSupportedMediaTypes).mockResolvedValue({ + image: ['.jpg'], + sidecar: ['.xmp'], + video: ['.mp4'], + }); + + testFolder = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'test-startWatch-')); + checkBulkUploadMocked = vi.mocked(checkBulkUpload); + checkBulkUploadMocked.mockResolvedValue({ + results: [], + }); + }); + + it('should start watching a directory and upload new files', async () => { + const testFilePath = path.join(testFolder, 'test.jpg'); + + await startWatch([testFolder], { concurrency: 1 }, { batchSize: 1, debounceTimeMs: 10 }); + await sleep(100); // to debounce the watcher from considering the test file as a existing file + await fs.promises.writeFile(testFilePath, 'testjpg'); + + await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000); + expect(checkBulkUpload).toHaveBeenCalledWith({ + assetBulkUploadCheckDto: { + assets: [ + expect.objectContaining({ + id: testFilePath, + }), + ], + }, + }); + }); + + it('should filter out unsupported files', async () => { + const testFilePath = path.join(testFolder, 'test.jpg'); + const unsupportedFilePath = path.join(testFolder, 'test.txt'); + + await startWatch([testFolder], { concurrency: 1 }, { batchSize: 1, debounceTimeMs: 10 }); + await sleep(100); // to debounce the watcher from considering the test file as a existing file + await fs.promises.writeFile(testFilePath, 'testjpg'); + await fs.promises.writeFile(unsupportedFilePath, 'testtxt'); + + await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000); + expect(checkBulkUpload).toHaveBeenCalledWith({ + assetBulkUploadCheckDto: { + assets: expect.arrayContaining([ + expect.objectContaining({ + id: testFilePath, + }), + ]), + }, + }); + + expect(checkBulkUpload).not.toHaveBeenCalledWith({ + assetBulkUploadCheckDto: { + assets: expect.arrayContaining([ + expect.objectContaining({ + id: unsupportedFilePath, + }), + ]), + }, + }); + }); + + it('should filger out ignored patterns', async () => { + const testFilePath = path.join(testFolder, 'test.jpg'); + const ignoredPattern = 'ignored'; + const ignoredFolder = path.join(testFolder, ignoredPattern); + await fs.promises.mkdir(ignoredFolder, { recursive: true }); + const ignoredFilePath = path.join(ignoredFolder, 'ignored.jpg'); + + await startWatch([testFolder], { concurrency: 1, ignore: ignoredPattern }, { batchSize: 1, debounceTimeMs: 10 }); + await sleep(100); // to debounce the watcher from considering the test file as a existing file + await fs.promises.writeFile(testFilePath, 'testjpg'); + await fs.promises.writeFile(ignoredFilePath, 'ignoredjpg'); + + await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000); + expect(checkBulkUpload).toHaveBeenCalledWith({ + assetBulkUploadCheckDto: { + assets: expect.arrayContaining([ + expect.objectContaining({ + id: testFilePath, + }), + ]), + }, + }); + + expect(checkBulkUpload).not.toHaveBeenCalledWith({ + assetBulkUploadCheckDto: { + assets: expect.arrayContaining([ + expect.objectContaining({ + id: ignoredFilePath, + }), + ]), + }, + }); + }); + + afterEach(async () => { + await fs.promises.rm(testFolder, { recursive: true, force: true }); + }); +}); diff --git a/cli/src/commands/asset.ts b/cli/src/commands/asset.ts index 4cf6742f24..d06b30e984 100644 --- a/cli/src/commands/asset.ts +++ b/cli/src/commands/asset.ts @@ -12,13 +12,18 @@ import { getSupportedMediaTypes, } from '@immich/sdk'; import byteSize from 'byte-size'; +import { Matcher, watch as watchFs } from 'chokidar'; import { MultiBar, Presets, SingleBar } from 'cli-progress'; import { chunk } from 'lodash-es'; +import micromatch from 'micromatch'; import { Stats, createReadStream } from 'node:fs'; import { stat, unlink } from 'node:fs/promises'; import path, { basename } from 'node:path'; import { Queue } from 'src/queue'; -import { BaseOptions, authenticate, crawl, sha1 } from 'src/utils'; +import { BaseOptions, Batcher, authenticate, crawl, sha1 } from 'src/utils'; + +const UPLOAD_WATCH_BATCH_SIZE = 100; +const UPLOAD_WATCH_DEBOUNCE_TIME_MS = 10_000; const s = (count: number) => (count === 1 ? '' : 's'); @@ -36,6 +41,8 @@ export interface UploadOptionsDto { albumName?: string; includeHidden?: boolean; concurrency: number; + progress?: boolean; + watch?: boolean; } class UploadFile extends File { @@ -55,19 +62,94 @@ class UploadFile extends File { } } +const uploadBatch = async (files: string[], options: UploadOptionsDto) => { + const { newFiles, duplicates } = await checkForDuplicates(files, options); + const newAssets = await uploadFiles(newFiles, options); + await updateAlbums([...newAssets, ...duplicates], options); + await deleteFiles(newFiles, options); +}; + +export const startWatch = async ( + paths: string[], + options: UploadOptionsDto, + { + batchSize = UPLOAD_WATCH_BATCH_SIZE, + debounceTimeMs = UPLOAD_WATCH_DEBOUNCE_TIME_MS, + }: { batchSize?: number; debounceTimeMs?: number } = {}, +) => { + const watcherIgnored: Matcher[] = []; + const { image, video } = await getSupportedMediaTypes(); + const extensions = new Set([...image, ...video]); + + if (options.ignore) { + watcherIgnored.push((path) => micromatch.contains(path, `**/${options.ignore}`)); + } + + const pathsBatcher = new Batcher({ + batchSize, + debounceTimeMs, + onBatch: async (paths: string[]) => { + const uniquePaths = [...new Set(paths)]; + await uploadBatch(uniquePaths, options); + }, + }); + + const onFile = async (path: string, stats?: Stats) => { + if (stats?.isDirectory()) { + return; + } + const ext = '.' + path.split('.').pop()?.toLowerCase(); + if (!ext || !extensions.has(ext)) { + return; + } + + if (!options.progress) { + // logging when progress is disabled as it can cause issues with the progress bar rendering + console.log(`Change detected: ${path}`); + } + pathsBatcher.add(path); + }; + const fsWatcher = watchFs(paths, { + ignoreInitial: true, + ignored: watcherIgnored, + alwaysStat: true, + awaitWriteFinish: true, + depth: options.recursive ? undefined : 1, + persistent: true, + }) + .on('add', onFile) + .on('change', onFile) + .on('error', (error) => console.error(`Watcher error: ${error}`)); + + process.on('SIGINT', async () => { + console.log('Exiting...'); + await fsWatcher.close(); + process.exit(); + }); +}; + export const upload = async (paths: string[], baseOptions: BaseOptions, options: UploadOptionsDto) => { await authenticate(baseOptions); const scanFiles = await scan(paths, options); + if (scanFiles.length === 0) { - console.log('No files found, exiting'); - return; + if (options.watch) { + console.log('No files found initially.'); + } else { + console.log('No files found, exiting'); + return; + } } - const { newFiles, duplicates } = await checkForDuplicates(scanFiles, options); - const newAssets = await uploadFiles(newFiles, options); - await updateAlbums([...newAssets, ...duplicates], options); - await deleteFiles(newFiles, options); + if (options.watch) { + console.log('Watching for changes...'); + await startWatch(paths, options); + // watcher does not handle the initial scan + // as the scan() is a more efficient quick start with batched results + } + + await uploadBatch(scanFiles, options); }; const scan = async (pathsToCrawl: string[], options: UploadOptionsDto) => { @@ -85,19 +167,25 @@ const scan = async (pathsToCrawl: string[], options: UploadOptionsDto) => { return files; }; -export const checkForDuplicates = async (files: string[], { concurrency, skipHash }: UploadOptionsDto) => { +export const checkForDuplicates = async (files: string[], { concurrency, skipHash, progress }: UploadOptionsDto) => { if (skipHash) { console.log('Skipping hash check, assuming all files are new'); return { newFiles: files, duplicates: [] }; } - const multiBar = new MultiBar( - { format: '{message} | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' }, - Presets.shades_classic, - ); + let multiBar: MultiBar | undefined; - const hashProgressBar = multiBar.create(files.length, 0, { message: 'Hashing files ' }); - const checkProgressBar = multiBar.create(files.length, 0, { message: 'Checking for duplicates' }); + if (progress) { + multiBar = new MultiBar( + { format: '{message} | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' }, + Presets.shades_classic, + ); + } else { + console.log(`Received ${files.length} files, hashing...`); + } + + const hashProgressBar = multiBar?.create(files.length, 0, { message: 'Hashing files ' }); + const checkProgressBar = multiBar?.create(files.length, 0, { message: 'Checking for duplicates' }); const newFiles: string[] = []; const duplicates: Asset[] = []; @@ -117,7 +205,7 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas } } - checkProgressBar.increment(assets.length); + checkProgressBar?.increment(assets.length); }, { concurrency, retry: 3 }, ); @@ -137,7 +225,7 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas void checkBulkUploadQueue.push(batch); } - hashProgressBar.increment(); + hashProgressBar?.increment(); return results; }, { concurrency, retry: 3 }, @@ -155,7 +243,7 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas await checkBulkUploadQueue.drained(); - multiBar.stop(); + multiBar?.stop(); console.log(`Found ${newFiles.length} new files and ${duplicates.length} duplicate${s(duplicates.length)}`); @@ -171,7 +259,10 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas return { newFiles, duplicates }; }; -export const uploadFiles = async (files: string[], { dryRun, concurrency }: UploadOptionsDto): Promise => { +export const uploadFiles = async ( + files: string[], + { dryRun, concurrency, progress }: UploadOptionsDto, +): Promise => { if (files.length === 0) { console.log('All assets were already uploaded, nothing to do.'); return []; @@ -191,12 +282,20 @@ export const uploadFiles = async (files: string[], { dryRun, concurrency }: Uplo return files.map((filepath) => ({ id: '', filepath })); } - const uploadProgress = new SingleBar( - { format: 'Uploading assets | {bar} | {percentage}% | ETA: {eta_formatted} | {value_formatted}/{total_formatted}' }, - Presets.shades_classic, - ); - uploadProgress.start(totalSize, 0); - uploadProgress.update({ value_formatted: 0, total_formatted: byteSize(totalSize) }); + let uploadProgress: SingleBar | undefined; + + if (progress) { + uploadProgress = new SingleBar( + { + format: 'Uploading assets | {bar} | {percentage}% | ETA: {eta_formatted} | {value_formatted}/{total_formatted}', + }, + Presets.shades_classic, + ); + } else { + console.log(`Uploading ${files.length} asset${s(files.length)} (${byteSize(totalSize)})`); + } + uploadProgress?.start(totalSize, 0); + uploadProgress?.update({ value_formatted: 0, total_formatted: byteSize(totalSize) }); let duplicateCount = 0; let duplicateSize = 0; @@ -222,7 +321,7 @@ export const uploadFiles = async (files: string[], { dryRun, concurrency }: Uplo successSize += stats.size ?? 0; } - uploadProgress.update(successSize, { value_formatted: byteSize(successSize + duplicateSize) }); + uploadProgress?.update(successSize, { value_formatted: byteSize(successSize + duplicateSize) }); return response; }, @@ -235,7 +334,7 @@ export const uploadFiles = async (files: string[], { dryRun, concurrency }: Uplo await queue.drained(); - uploadProgress.stop(); + uploadProgress?.stop(); console.log(`Successfully uploaded ${successCount} new asset${s(successCount)} (${byteSize(successSize)})`); if (duplicateCount > 0) { diff --git a/cli/src/index.ts b/cli/src/index.ts index 341a70bef0..5da4b50722 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -69,6 +69,13 @@ program .default(4), ) .addOption(new Option('--delete', 'Delete local assets after upload').env('IMMICH_DELETE_ASSETS')) + .addOption(new Option('--no-progress', 'Hide progress bars').env('IMMICH_PROGRESS_BAR').default(true)) + .addOption( + new Option('--watch', 'Watch for changes and upload automatically') + .env('IMMICH_WATCH_CHANGES') + .default(false) + .implies({ progress: false }), + ) .argument('[paths...]', 'One or more paths to assets to be uploaded') .action((paths, options) => upload(paths, program.opts(), options)); diff --git a/cli/src/utils.spec.ts b/cli/src/utils.spec.ts index 93f031872b..5dd28a55e3 100644 --- a/cli/src/utils.spec.ts +++ b/cli/src/utils.spec.ts @@ -1,6 +1,7 @@ import mockfs from 'mock-fs'; import { readFileSync } from 'node:fs'; -import { CrawlOptions, crawl } from 'src/utils'; +import { Batcher, CrawlOptions, crawl } from 'src/utils'; +import { Mock } from 'vitest'; interface Test { test: string; @@ -303,3 +304,38 @@ describe('crawl', () => { } }); }); + +describe('Batcher', () => { + let batcher: Batcher; + let onBatch: Mock; + beforeEach(() => { + onBatch = vi.fn(); + batcher = new Batcher({ batchSize: 2, onBatch }); + }); + + it('should trigger onBatch() when a batch limit is reached', async () => { + batcher.add('a'); + batcher.add('b'); + batcher.add('c'); + expect(onBatch).toHaveBeenCalledOnce(); + expect(onBatch).toHaveBeenCalledWith(['a', 'b']); + }); + + it('should trigger onBatch() when flush() is called', async () => { + batcher.add('a'); + batcher.flush(); + expect(onBatch).toHaveBeenCalledOnce(); + expect(onBatch).toHaveBeenCalledWith(['a']); + }); + + it('should trigger onBatch() when debounce time reached', async () => { + vi.useFakeTimers(); + batcher = new Batcher({ batchSize: 2, debounceTimeMs: 100, onBatch }); + batcher.add('a'); + expect(onBatch).not.toHaveBeenCalled(); + vi.advanceTimersByTime(200); + expect(onBatch).toHaveBeenCalledOnce(); + expect(onBatch).toHaveBeenCalledWith(['a']); + vi.useRealTimers(); + }); +}); diff --git a/cli/src/utils.ts b/cli/src/utils.ts index 27cc2f9e08..eae5164394 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -172,3 +172,64 @@ export const sha1 = (filepath: string) => { rs.on('end', () => resolve(hash.digest('hex'))); }); }; + +/** + * Batches items and calls onBatch to process them + * when the batch size is reached or the debounce time has passed. + */ +export class Batcher { + private items: T[] = []; + private readonly batchSize: number; + private readonly debounceTimeMs?: number; + private readonly onBatch: (items: T[]) => void; + private debounceTimer?: NodeJS.Timeout; + + constructor({ + batchSize, + debounceTimeMs, + onBatch, + }: { + batchSize: number; + debounceTimeMs?: number; + onBatch: (items: T[]) => Promise; + }) { + this.batchSize = batchSize; + this.debounceTimeMs = debounceTimeMs; + this.onBatch = onBatch; + } + + private setDebounceTimer() { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + if (this.debounceTimeMs) { + this.debounceTimer = setTimeout(() => this.flush(), this.debounceTimeMs); + } + } + + private clearDebounceTimer() { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + this.debounceTimer = undefined; + } + } + + add(item: T) { + this.items.push(item); + this.setDebounceTimer(); + if (this.items.length >= this.batchSize) { + this.flush(); + } + } + + flush() { + this.clearDebounceTimer(); + if (this.items.length === 0) { + return; + } + + this.onBatch(this.items); + + this.items = []; + } +} diff --git a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl index cc3eaa4143..417f262157 100644 --- a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl +++ b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl @@ -2,37 +2,37 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/cloudflare/cloudflare" { - version = "4.50.0" - constraints = "4.50.0" + version = "4.52.0" + constraints = "4.52.0" hashes = [ - "h1:0qvD5ZKn2tMZ8cOjQrUSITIC9tKCZbrSaSswV9lOyiU=", - "h1:4N0gplrZ0zOsJv3Kx1VfIx2FwrZHbYU0Un2yfiLZIGQ=", - "h1:81AMQq4kNKU/35U8ElQegUxG4E6xB0erIjG5xVmjIyo=", - "h1:EEQNADUmV3IL6x00yzy04i7OCSLeOMgM9XQkV3w71gA=", - "h1:HD0KI7td6oiSSAnJNn8UPSGf+hKiTo4JVQYfAiU1SqM=", - "h1:Hl+o5LtcvZg2f3l1hh9vaG/DFK6k+dTIZSeM0lXyfpo=", - "h1:ZUO2oIJ6jtZdvl816h0cEIiIeZ/fFCF64+abGEVxZZM=", - "h1:Zio80fnEeUKdlSOhTVskMEFSLUQ6TMsMKnXc+Dy2P2A=", - "h1:aLLvg36evTyqjtXGV2MjAV8imktXFmry7p/xCu9GQC4=", - "h1:azL05eWyy2V8SWkbZZImPWvv8ynG4eqmrbZhjXBDFug=", - "h1:ckMysHY4fJmr7o58XMi+DdgOTB/U/Mf1u1JA9ly3g/I=", - "h1:jxOwjDNjt5WCb4YjjiMsman91O8Y+MAPz6UwJ4a6F+0=", - "h1:u4OfnjSLa4Wk1IUFAzrvMnGgr8MvRHEWVDHEScPK2E8=", - "h1:wQkR1oeSkzlHn3rnVuLJRJLBHlg4EHt7Y64DeTjfkjQ=", - "zh:0ef99ed39472a94e6a0d6fa733cf0a46bce3bf66eba2873efae8846efdddc237", - "zh:2929cbbffcead171d45c88e4a7a59e9c013ea775dafa68b10da8db7cd04b6140", - "zh:462601c87118088e1a718842e367af7d8e7620598d426980a6d6b33de759865e", - "zh:56766eb62a74a9d88d9efb8486dd3a0c5c9db873d0a980ae9ef1e8af27d74231", - "zh:6b4e8810d99498a5a20a5872982a0f1354e79cfc4a7dfe7cc656f1c7eaae47d8", - "zh:6d65bdb4ec94b6eecc8abe26d94e2ca09262dc1e7a9934db829f418be0119920", - "zh:71adeaf31e41a358ec6095004062e43f56ee7d4b2504e5613ab351d511695641", + "h1:2BEJyXJtYC4B4nda/WCYUmuJYDaYk88F8t1pwPzr0iQ=", + "h1:4IASk5SESeWKQ7JU0+M7KApuF5mZyklvwMXPBabim3c=", + "h1:5ImZxxALSnWfH/4EXw/wFirSmk5Tr0ACmcysy51AafE=", + "h1:6TJ3dxLSin4ZKBJLsZDn95H2ZYnGm8S7GGHvvXuuMQU=", + "h1:IzTUjg9kQ4N3qizP9CjYLeHwjsuGgtxwXvfUQWyOLcA=", + "h1:NTaOQfYINA0YTG/V1/9+SYtgX1it63+cBugj4WK4FWc=", + "h1:PXH48LuJn329sCfMXprdMDk51EZaWFyajVvS03qhQLs=", + "h1:Pi5M+GeoMSN2eJ6QnIeXjBf19O+rby/74CfB2ocpv20=", + "h1:ShXZ2ZjBvm3thfoPPzPT8+OhyismnydQVkUAfI8X12w=", + "h1:WQ9hu0Wge2msBbODfottCSKgu8oKUrw4Opz+fDPVVHk=", + "h1:Z5yXML2DE0uH9UU+M0ut9JMQAORcwVZz1CxBHzeBmao=", + "h1:jqI2qKknpleS3JDSplyGYHMu0u9K/tor1ZOjFwDgEMk=", + "h1:kgfutDh14Q5nw4eg6qGFamFxIiY8Ae0FPKRBLDOzpcI=", + "h1:zCAO7GZmfYhWb+i6TfqlqhMeDyPZWGio2IzEzAh3YTs=", + "zh:19be1a91c982b902c42aba47766860dfa5dc151eed1e95fd39ca642229381ef0", + "zh:1de451c4d1ecf7efbe67b6dace3426ba810711afdd644b0f1b870364c8ae91f8", + "zh:352b4a2120173298622e669258744554339d959ac3a95607b117a48ee4a83238", + "zh:3c6f1346d9154afbd2d558fabb4b0150fc8d559aa961254144fe1bc17fe6032f", + "zh:4c4c92d53fb535b1e0eff26f222bbd627b97d3b4c891ec9c321268676d06152f", + "zh:53276f68006c9ceb7cdb10a6ccf91a5c1eadd1407a28edb5741e84e88d7e29e8", + "zh:7925a97773948171a63d4f65bb81ee92fd6d07a447e36012977313293a5435c9", + "zh:7dfb0a4496cfe032437386d0a2cd9229a1956e9c30bd920923c141b0f0440060", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:89761c15908ccc2cf9c50bb5cb3be45d3ad0c45fc7c608c6b95f48c0288b7160", - "zh:8cc5d7c5939da89cfd01f3e51c84f3576564783acea9db86bd9e32049805ed96", - "zh:987cff8225b1dd436cdcb4fc6228689ae7e4281de6896412a2a9a3325c49f05e", - "zh:991e83ebb89867d71e01a1c215ed159efb425683b0a44707be8579eb0a337f06", - "zh:ab8177ae2d8f5cfa90043a6f867435012cae115f6061b832a7e2462e0ae87a67", - "zh:d1ca34df1398f201274a6a18102975148c10ca15aa43cfc56cc9897620929509", - "zh:d34946f70201baf6dda03e3b294c6bbe40d95d0278e97b9f636ded94822b24ac", + "zh:8d4aa79f0a414bb4163d771063c70cd991c8fac6c766e685bac2ee12903c5bd6", + "zh:a67540c13565616a7e7e51ee9366e88b0dc60046e1d75c72680e150bd02725bb", + "zh:a936383a4767f5393f38f622e92bf2d0c03fe04b69c284951f27345766c7b31b", + "zh:d4887d73c466ff036eecf50ad6404ba38fd82ea4855296b1846d244b0f13c380", + "zh:e9093c8bd5b6cd99c81666e315197791781b8f93afa14fc2e0f732d1bb2a44b7", + "zh:efd3b3f1ec59a37f635aa1d4efcf178734c2fcf8ddb0d56ea690bec342da8672", ] } diff --git a/deployment/modules/cloudflare/docs-release/config.tf b/deployment/modules/cloudflare/docs-release/config.tf index 9cfe295e85..cd370f9353 100644 --- a/deployment/modules/cloudflare/docs-release/config.tf +++ b/deployment/modules/cloudflare/docs-release/config.tf @@ -5,7 +5,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = "4.50.0" + version = "4.52.0" } } } diff --git a/deployment/modules/cloudflare/docs/.terraform.lock.hcl b/deployment/modules/cloudflare/docs/.terraform.lock.hcl index cc3eaa4143..417f262157 100644 --- a/deployment/modules/cloudflare/docs/.terraform.lock.hcl +++ b/deployment/modules/cloudflare/docs/.terraform.lock.hcl @@ -2,37 +2,37 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/cloudflare/cloudflare" { - version = "4.50.0" - constraints = "4.50.0" + version = "4.52.0" + constraints = "4.52.0" hashes = [ - "h1:0qvD5ZKn2tMZ8cOjQrUSITIC9tKCZbrSaSswV9lOyiU=", - "h1:4N0gplrZ0zOsJv3Kx1VfIx2FwrZHbYU0Un2yfiLZIGQ=", - "h1:81AMQq4kNKU/35U8ElQegUxG4E6xB0erIjG5xVmjIyo=", - "h1:EEQNADUmV3IL6x00yzy04i7OCSLeOMgM9XQkV3w71gA=", - "h1:HD0KI7td6oiSSAnJNn8UPSGf+hKiTo4JVQYfAiU1SqM=", - "h1:Hl+o5LtcvZg2f3l1hh9vaG/DFK6k+dTIZSeM0lXyfpo=", - "h1:ZUO2oIJ6jtZdvl816h0cEIiIeZ/fFCF64+abGEVxZZM=", - "h1:Zio80fnEeUKdlSOhTVskMEFSLUQ6TMsMKnXc+Dy2P2A=", - "h1:aLLvg36evTyqjtXGV2MjAV8imktXFmry7p/xCu9GQC4=", - "h1:azL05eWyy2V8SWkbZZImPWvv8ynG4eqmrbZhjXBDFug=", - "h1:ckMysHY4fJmr7o58XMi+DdgOTB/U/Mf1u1JA9ly3g/I=", - "h1:jxOwjDNjt5WCb4YjjiMsman91O8Y+MAPz6UwJ4a6F+0=", - "h1:u4OfnjSLa4Wk1IUFAzrvMnGgr8MvRHEWVDHEScPK2E8=", - "h1:wQkR1oeSkzlHn3rnVuLJRJLBHlg4EHt7Y64DeTjfkjQ=", - "zh:0ef99ed39472a94e6a0d6fa733cf0a46bce3bf66eba2873efae8846efdddc237", - "zh:2929cbbffcead171d45c88e4a7a59e9c013ea775dafa68b10da8db7cd04b6140", - "zh:462601c87118088e1a718842e367af7d8e7620598d426980a6d6b33de759865e", - "zh:56766eb62a74a9d88d9efb8486dd3a0c5c9db873d0a980ae9ef1e8af27d74231", - "zh:6b4e8810d99498a5a20a5872982a0f1354e79cfc4a7dfe7cc656f1c7eaae47d8", - "zh:6d65bdb4ec94b6eecc8abe26d94e2ca09262dc1e7a9934db829f418be0119920", - "zh:71adeaf31e41a358ec6095004062e43f56ee7d4b2504e5613ab351d511695641", + "h1:2BEJyXJtYC4B4nda/WCYUmuJYDaYk88F8t1pwPzr0iQ=", + "h1:4IASk5SESeWKQ7JU0+M7KApuF5mZyklvwMXPBabim3c=", + "h1:5ImZxxALSnWfH/4EXw/wFirSmk5Tr0ACmcysy51AafE=", + "h1:6TJ3dxLSin4ZKBJLsZDn95H2ZYnGm8S7GGHvvXuuMQU=", + "h1:IzTUjg9kQ4N3qizP9CjYLeHwjsuGgtxwXvfUQWyOLcA=", + "h1:NTaOQfYINA0YTG/V1/9+SYtgX1it63+cBugj4WK4FWc=", + "h1:PXH48LuJn329sCfMXprdMDk51EZaWFyajVvS03qhQLs=", + "h1:Pi5M+GeoMSN2eJ6QnIeXjBf19O+rby/74CfB2ocpv20=", + "h1:ShXZ2ZjBvm3thfoPPzPT8+OhyismnydQVkUAfI8X12w=", + "h1:WQ9hu0Wge2msBbODfottCSKgu8oKUrw4Opz+fDPVVHk=", + "h1:Z5yXML2DE0uH9UU+M0ut9JMQAORcwVZz1CxBHzeBmao=", + "h1:jqI2qKknpleS3JDSplyGYHMu0u9K/tor1ZOjFwDgEMk=", + "h1:kgfutDh14Q5nw4eg6qGFamFxIiY8Ae0FPKRBLDOzpcI=", + "h1:zCAO7GZmfYhWb+i6TfqlqhMeDyPZWGio2IzEzAh3YTs=", + "zh:19be1a91c982b902c42aba47766860dfa5dc151eed1e95fd39ca642229381ef0", + "zh:1de451c4d1ecf7efbe67b6dace3426ba810711afdd644b0f1b870364c8ae91f8", + "zh:352b4a2120173298622e669258744554339d959ac3a95607b117a48ee4a83238", + "zh:3c6f1346d9154afbd2d558fabb4b0150fc8d559aa961254144fe1bc17fe6032f", + "zh:4c4c92d53fb535b1e0eff26f222bbd627b97d3b4c891ec9c321268676d06152f", + "zh:53276f68006c9ceb7cdb10a6ccf91a5c1eadd1407a28edb5741e84e88d7e29e8", + "zh:7925a97773948171a63d4f65bb81ee92fd6d07a447e36012977313293a5435c9", + "zh:7dfb0a4496cfe032437386d0a2cd9229a1956e9c30bd920923c141b0f0440060", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:89761c15908ccc2cf9c50bb5cb3be45d3ad0c45fc7c608c6b95f48c0288b7160", - "zh:8cc5d7c5939da89cfd01f3e51c84f3576564783acea9db86bd9e32049805ed96", - "zh:987cff8225b1dd436cdcb4fc6228689ae7e4281de6896412a2a9a3325c49f05e", - "zh:991e83ebb89867d71e01a1c215ed159efb425683b0a44707be8579eb0a337f06", - "zh:ab8177ae2d8f5cfa90043a6f867435012cae115f6061b832a7e2462e0ae87a67", - "zh:d1ca34df1398f201274a6a18102975148c10ca15aa43cfc56cc9897620929509", - "zh:d34946f70201baf6dda03e3b294c6bbe40d95d0278e97b9f636ded94822b24ac", + "zh:8d4aa79f0a414bb4163d771063c70cd991c8fac6c766e685bac2ee12903c5bd6", + "zh:a67540c13565616a7e7e51ee9366e88b0dc60046e1d75c72680e150bd02725bb", + "zh:a936383a4767f5393f38f622e92bf2d0c03fe04b69c284951f27345766c7b31b", + "zh:d4887d73c466ff036eecf50ad6404ba38fd82ea4855296b1846d244b0f13c380", + "zh:e9093c8bd5b6cd99c81666e315197791781b8f93afa14fc2e0f732d1bb2a44b7", + "zh:efd3b3f1ec59a37f635aa1d4efcf178734c2fcf8ddb0d56ea690bec342da8672", ] } diff --git a/deployment/modules/cloudflare/docs/config.tf b/deployment/modules/cloudflare/docs/config.tf index 9cfe295e85..cd370f9353 100644 --- a/deployment/modules/cloudflare/docs/config.tf +++ b/deployment/modules/cloudflare/docs/config.tf @@ -5,7 +5,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = "4.50.0" + version = "4.52.0" } } } diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index b655c6e4b0..78254c7662 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -1,4 +1,13 @@ -# See: +# +# WARNING: To install Immich, follow our guide: https://immich.app/docs/install/docker-compose +# +# Make sure to use the docker-compose.yml of the current release: +# +# https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml +# +# The compose file on main may not be compatible with the latest release. + +# For development see: # - https://immich.app/docs/developer/setup # - https://immich.app/docs/developer/troubleshooting @@ -16,7 +25,7 @@ services: context: ../ dockerfile: server/Dockerfile target: dev - restart: always + restart: unless-stopped volumes: - ../server:/usr/src/app - ../open-api:/usr/src/open-api @@ -107,13 +116,13 @@ services: redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:905c4ee67b8e0aa955331960d2aa745781e6bd89afc44a8584bfd13bc890f0ae + image: redis:6.2-alpine@sha256:148bb5411c184abd288d9aaed139c98123eeb8824c5d3fce03cf721db58066d8 healthcheck: test: redis-cli ping || exit 1 database: container_name: immich_postgres - image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 + image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52 env_file: - .env environment: diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index cc02750921..adb00dfbed 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -1,3 +1,12 @@ +# +# WARNING: To install Immich, follow our guide: https://immich.app/docs/install/docker-compose +# +# Make sure to use the docker-compose.yml of the current release: +# +# https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml +# +# The compose file on main may not be compatible with the latest release. + name: immich-prod services: @@ -47,14 +56,14 @@ services: redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:905c4ee67b8e0aa955331960d2aa745781e6bd89afc44a8584bfd13bc890f0ae + image: redis:6.2-alpine@sha256:148bb5411c184abd288d9aaed139c98123eeb8824c5d3fce03cf721db58066d8 healthcheck: test: redis-cli ping || exit 1 restart: always database: container_name: immich_postgres - image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 + image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52 env_file: - .env environment: @@ -81,7 +90,7 @@ services: container_name: immich_prometheus ports: - 9090:9090 - image: prom/prometheus@sha256:6559acbd5d770b15bb3c954629ce190ac3cbbdb2b7f1c30f0385c4e05104e218 + image: prom/prometheus@sha256:6927e0919a144aa7616fd0137d4816816d42f6b816de3af269ab065250859a62 volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-data:/prometheus @@ -93,7 +102,7 @@ services: command: [ './run.sh', '-disable-reporting' ] ports: - 3000:3000 - image: grafana/grafana:11.4.0-ubuntu@sha256:afccec22ba0e4815cca1d2bf3836e414322390dc78d77f1851976ffa8d61051c + image: grafana/grafana:11.5.2-ubuntu@sha256:8b5858c447e06fd7a89006b562ba7bba7c4d5813600c7982374c41852adefaeb volumes: - grafana-data:/var/lib/grafana diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 183b21fd0a..6be3189b41 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,10 +1,11 @@ # -# WARNING: Make sure to use the docker-compose.yml of the current release: +# WARNING: To install Immich, follow our guide: https://immich.app/docs/install/docker-compose +# +# Make sure to use the docker-compose.yml of the current release: # # https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml # # The compose file on main may not be compatible with the latest release. -# name: immich @@ -48,14 +49,14 @@ services: redis: container_name: immich_redis - image: docker.io/redis:6.2-alpine@sha256:905c4ee67b8e0aa955331960d2aa745781e6bd89afc44a8584bfd13bc890f0ae + image: docker.io/redis:6.2-alpine@sha256:148bb5411c184abd288d9aaed139c98123eeb8824c5d3fce03cf721db58066d8 healthcheck: test: redis-cli ping || exit 1 restart: always database: container_name: immich_postgres - image: docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 + image: docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52 environment: POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_USER: ${DB_USERNAME} diff --git a/docker/hwaccel.transcoding.yml b/docker/hwaccel.transcoding.yml index 33fb7b3c06..60ee7e8fa3 100644 --- a/docker/hwaccel.transcoding.yml +++ b/docker/hwaccel.transcoding.yml @@ -48,6 +48,7 @@ services: vaapi-wsl: # use this for VAAPI if you're running Immich in WSL2 devices: - /dev/dri:/dev/dri + - /dev/dxg:/dev/dxg volumes: - /usr/lib/wsl:/usr/lib/wsl environment: diff --git a/docs/.nvmrc b/docs/.nvmrc index d5b283a3ac..7d41c735d7 100644 --- a/docs/.nvmrc +++ b/docs/.nvmrc @@ -1 +1 @@ -22.13.1 +22.14.0 diff --git a/docs/docs/FAQ.mdx b/docs/docs/FAQ.mdx index c605c564cd..96ac03c9dc 100644 --- a/docs/docs/FAQ.mdx +++ b/docs/docs/FAQ.mdx @@ -62,6 +62,10 @@ Instead of these experimental features, we recommend using the URL switching fea We are not actively developing these features and will not be able to provide support, but welcome contributions to improve them. Please discuss any large PRs with our dev team to ensure your time is not wasted. +### Why isn't the mobile app updated yet? + +The app stores can take a few days to approve new builds of the app. If you're impatient, android APKs can be downloaded from the GitHub releases. + --- ## Assets @@ -93,7 +97,7 @@ Make sure to [set your reverse proxy](/docs/administration/reverse-proxy/) to al Also, check the disk space of your reverse proxy. In some cases, proxies cache requests to disk before passing them on, and if disk space runs out, the request fails. -If you are using Cloudflare Tunnel, please know that they set a maxiumum filesize of 100 MB that cannot be changed. +If you are using Cloudflare Tunnel, please know that they set a maximum filesize of 100 MB that cannot be changed. At times, files larger than this may work, potentially up to 1 GB. However, the official limit is 100 MB. If you are having issues, we recommend switching to a different network deployment. @@ -113,7 +117,7 @@ See [Backup and Restore](/docs/administration/backup-and-restore.md). ### Does Immich support reading existing face tag metadata? -No, it currently does not. There is an [open feature request on GitHub](https://github.com/immich-app/immich/discussions/4348). +Yes, it creates new faces and persons from the imported asset metadata. For details see the [feature request #4348](https://github.com/immich-app/immich/discussions/4348) and [PR #6455](https://github.com/immich-app/immich/pull/6455). ### Does Immich support the filtering of NSFW images? @@ -166,7 +170,7 @@ If you aren't able to or prefer not to mount Samba on the host (such as Windows Below is an example in the `docker-compose.yml`. Change your username, password, local IP, and share name, and see below where the line `- originals:/usr/src/app/originals`, -corrolates to the section where the volume `originals` was created. You can call this whatever you like, and map it to the docker container as you like. +correlates to the section where the volume `originals` was created. You can call this whatever you like, and map it to the docker container as you like. For example you could change `originals:` to `Photos:`, and change `- originals:/usr/src/app/originals` to `Photos:/usr/src/app/photos`. ```diff diff --git a/docs/docs/administration/backup-and-restore.md b/docs/docs/administration/backup-and-restore.md index cd58604e1f..817a7dca6d 100644 --- a/docs/docs/administration/backup-and-restore.md +++ b/docs/docs/administration/backup-and-restore.md @@ -30,6 +30,13 @@ As mentioned above, you should make your own backup of these together with the a You can adjust the schedule and amount of kept backups in the [admin settings](http://my.immich.app/admin/system-settings?isOpen=backup). By default, Immich will keep the last 14 backups and create a new backup every day at 2:00 AM. +#### Trigger Backup + +You are able to trigger a backup in the [admin job status page](http://my.immich.app/admin/jobs-status). +Visit the page, open the "Create job" modal from the top right, select "Backup Database" and click "Confirm". +A job will run and trigger a backup, you can verify this worked correctly by checking the logs or the backup folder. +This backup will count towards the last X backups that will be kept based on your settings. + #### Restoring We hope to make restoring simpler in future versions, for now you can find the backups in the `UPLOAD_LOCATION/backups` folder on your host. @@ -53,7 +60,7 @@ docker compose create # Create Docker containers for Immich apps witho docker start immich_postgres # Start Postgres server sleep 10 # Wait for Postgres server to start up # Check the database user if you deviated from the default -gunzip < "/path/to/backup/dump.sql.gz" \ +gunzip --stdout "/path/to/backup/dump.sql.gz" \ | sed "s/SELECT pg_catalog.set_config('search_path', '', false);/SELECT pg_catalog.set_config('search_path', 'public, pg_catalog', true);/g" \ | docker exec -i immich_postgres psql --dbname=postgres --username= # Restore Backup docker compose up -d # Start remainder of Immich apps @@ -76,10 +83,8 @@ docker compose create # Create Docker containers for docker start immich_postgres # Start Postgres server sleep 10 # Wait for Postgres server to start up docker exec -it immich_postgres bash # Enter the Docker shell and run the following command -# Check the database user if you deviated from the default. If your backup ends in `.gz`, replace `cat` with `gunzip` -cat < "/dump.sql" \ -| sed "s/SELECT pg_catalog.set_config('search_path', '', false);/SELECT pg_catalog.set_config('search_path', 'public, pg_catalog', true);/g" \ -| psql --dbname=postgres --username= # Restore Backup +# Check the database user if you deviated from the default. If your backup ends in `.gz`, replace `cat` with `gunzip --stdout` +cat "/dump.sql" | sed "s/SELECT pg_catalog.set_config('search_path', '', false);/SELECT pg_catalog.set_config('search_path', 'public, pg_catalog', true);/g" | psql --dbname=postgres --username= exit # Exit the Docker shell docker compose up -d # Start remainder of Immich apps ``` diff --git a/docs/docs/administration/postgres-standalone.md b/docs/docs/administration/postgres-standalone.md index 9be69906ab..2ca23e195f 100644 --- a/docs/docs/administration/postgres-standalone.md +++ b/docs/docs/administration/postgres-standalone.md @@ -70,4 +70,4 @@ When installing a new version of pgvecto.rs, you will need to manually update th If you get the error `driverError: error: permission denied for view pg_vector_index_stat`, you can fix this by connecting to the Immich database and running `GRANT SELECT ON TABLE pg_vector_index_stat TO ;`. -[vectors-install]: https://docs.pgvecto.rs/getting-started/installation.html +[vectors-install]: https://docs.vectorchord.ai/getting-started/installation.html diff --git a/docs/docs/administration/system-settings.md b/docs/docs/administration/system-settings.md index 92b910a01b..f241050136 100644 --- a/docs/docs/administration/system-settings.md +++ b/docs/docs/administration/system-settings.md @@ -98,6 +98,14 @@ The default Immich log level is `Log` (commonly known as `Info`). The Immich adm Through this setting, you can manage all the settings related to machine learning in Immich, from the setting of remote machine learning to the model and its parameters You can choose to disable a certain type of machine learning, for example smart search or facial recognition. +### URL + +The built in (`http://immich-machine-learning:3003`) machine learning server will be configured by default, but you can change this or add additional servers. + +Hosting the `immich-machine-learning` container on a machine with a more powerful GPU can be helpful to for processing a large number of photos (such as during batch import) or for faster search. + +If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last. Servers that don't respond will be temporarily ignored until they come back online. + ### Smart Search The [smart search](/docs/features/searching) settings allow you to change the [CLIP model](https://openai.com/research/clip). Larger models will typically provide [more accurate search results](https://github.com/immich-app/immich/discussions/11862) but consume more processing power and RAM. When [changing the CLIP model](/docs/FAQ#can-i-use-a-custom-clip-model) it is mandatory to re-run the Smart Search job on all images to fully apply the change. diff --git a/docs/docs/developer/architecture.mdx b/docs/docs/developer/architecture.mdx index 2910f4db56..a8d38ba5c1 100644 --- a/docs/docs/developer/architecture.mdx +++ b/docs/docs/developer/architecture.mdx @@ -50,19 +50,18 @@ The Immich CLI is an [npm](https://www.npmjs.com/) package that lets users contr The Immich backend is divided into several services, which are run as individual docker containers. -1. `immich-server` - Handle and respond to REST API requests -1. `immich-microservices` - Execute background jobs (thumbnail generation, metadata extraction, transcoding, etc.) +1. `immich-server` - Handle and respond to REST API requests, execute background jobs (thumbnail generation, metadata extraction, transcoding, etc.) 1. `immich-machine-learning` - Execute machine learning models 1. `postgres` - Persistent data storage -1. `redis`- Queue management for `immich-microservices` +1. `redis`- Queue management for background jobs ### Immich Server -The Immich Server is a [TypeScript](https://www.typescriptlang.org/) project written for [Node.js](https://nodejs.org/). It uses the [Nest.js](https://nestjs.com) framework, with [TypeORM](https://typeorm.io/) for database management. The server codebase also loosely follows the [Hexagonal Architecture](). Specifically, we aim to separate technology specific implementations (`infra/`) from core business logic (`domain/`). +The Immich Server is a [TypeScript](https://www.typescriptlang.org/) project written for [Node.js](https://nodejs.org/). It uses the [Nest.js](https://nestjs.com) framework, [Express](https://expressjs.com/) server, and the query builder [Kysely](https://kysely.dev/). The server codebase also loosely follows the [Hexagonal Architecture](). Specifically, we aim to separate technology specific implementations (`src/repositories`) from core business logic (`src/services`). -#### REST Endpoints +### API Endpoints -The server is a list of HTTP endpoints and associated handlers (controllers). Each controller usually implements the following CRUD operations: +An incoming HTTP request is mapped to a controller (`src/controllers`). Controllers are collections of HTTP endpoints. Each controller usually implements the following CRUD operations for its respective resource type: - `POST` `/` - **Create** - `GET` `/` - **Read** (all) @@ -70,13 +69,13 @@ The server is a list of HTTP endpoints and associated handlers (controllers). Ea - `PUT` `//:id` - **Updated** (by id) - `DELETE` `//:id` - **Delete** (by id) -#### DTOs +### Domain Transfer Objects (DTOs) The server uses [Domain Transfer Objects](https://en.wikipedia.org/wiki/Data_transfer_object) as public interfaces for the inputs (query, params, and body) and outputs (response) for each endpoint. DTOs translate to [OpenAPI](./open-api.md) schemas and control the generated code used by each client. -### Microservices +### Background Jobs -The Immich Microservices image uses the same `Dockerfile` as the Immich Server, but with a different entrypoint. The Immich Microservices service mainly handles executing jobs, which include the following: +Immich uses a [worker](https://github.com/immich-app/immich/blob/main/server/src/utils/misc.ts#L266) to run background jobs. These jobs include: - Thumbnail Generation - Metadata Extraction diff --git a/docs/docs/developer/setup.md b/docs/docs/developer/setup.md index f341c3e9cb..eb84b598e2 100644 --- a/docs/docs/developer/setup.md +++ b/docs/docs/developer/setup.md @@ -76,7 +76,7 @@ To see local changes to `@immich/ui` in Immich, do the following: ### Mobile app -The mobile app `(/mobile)` will required Flutter toolchain 3.13.x to be installed on your system. +The mobile app `(/mobile)` will required Flutter toolchain 3.13.x and FVM to be installed on your system. Please refer to the [Flutter's official documentation](https://flutter.dev/docs/get-started/install) for more information on setting up the toolchain on your machine. diff --git a/docs/docs/features/facial-recognition.md b/docs/docs/features/facial-recognition.md index 32ca6c87ba..f0dec55484 100644 --- a/docs/docs/features/facial-recognition.md +++ b/docs/docs/features/facial-recognition.md @@ -69,6 +69,8 @@ Navigating to Administration > Settings > Machine Learning Settings > Facial Rec :::tip It's better to only tweak the parameters here than to set them to something very different unless you're ready to test a variety of options. If you do need to set a parameter to a strict setting, relaxing other settings can be a good option to compensate, and vice versa. + +You can learn how the tune the result in this [Guide](/docs/guides/better-facial-clusters) ::: ### Facial recognition model diff --git a/docs/docs/features/libraries.md b/docs/docs/features/libraries.md index 6a1dba9eba..c09fd5043c 100644 --- a/docs/docs/features/libraries.md +++ b/docs/docs/features/libraries.md @@ -37,7 +37,7 @@ To validate that Immich can reach your external library, start a shell inside th ### Exclusion Patterns -By default, all files in the import paths will be added to the library. If there are files that should not be added, exclusion patterns can be used to exclude them. Exclusion patterns are glob patterns are matched against the full file path. If a file matches an exclusion pattern, it will not be added to the library. Exclusion patterns can be added in the Scan Settings page for each library. Under the hood, Immich uses the [glob](https://www.npmjs.com/package/glob) package to match patterns, so please refer to [their documentation](https://github.com/isaacs/node-glob#glob-primer) to see what patterns are supported. +By default, all files in the import paths will be added to the library. If there are files that should not be added, exclusion patterns can be used to exclude them. Exclusion patterns are glob patterns are matched against the full file path. If a file matches an exclusion pattern, it will not be added to the library. Exclusion patterns can be added in the Scan Settings page for each library. Some basic examples: @@ -48,7 +48,11 @@ Some basic examples: Special characters such as @ should be escaped, for instance: -- `**/\@eadir/**` will exclude all files in any directory named `@eadir` +- `**/\@eaDir/**` will exclude all files in any directory named `@eaDir` + +:::info +Internally, Immich uses the [glob](https://www.npmjs.com/package/glob) package to process exclusion patterns, and sometimes those patterns are translated into [Postgres LIKE patterns](https://www.postgresql.org/docs/current/functions-matching.html). The intention is to support basic folder exclusions but we recommend against advanced usage since those can't reliably be translated to the Postgres syntax. Please refer to the [glob documentation](https://github.com/isaacs/node-glob#glob-primer) for a basic overview on glob patterns. +::: ### Automatic watching (EXPERIMENTAL) @@ -58,7 +62,7 @@ If your photos are on a network drive, automatic file watching likely won't work #### Troubleshooting -If you encounter an `ENOSPC` error, you need to increase your file watcher limit. In sysctl, this key is called `fs.inotify.max_user_watched` and has a default value of 8192. Increase this number to a suitable value greater than the number of files you will be watching. Note that Immich has to watch all files in your import paths including any ignored files. +If you encounter an `ENOSPC` error, you need to increase your file watcher limit. In sysctl, this key is called `fs.inotify.max_user_watches` and has a default value of 8192. Increase this number to a suitable value greater than the number of files you will be watching. Note that Immich has to watch all files in your import paths including any ignored files. ``` ERROR [LibraryService] Library watcher for library c69faf55-f96d-4aa0-b83b-2d80cbc27d98 encountered error: Error: ENOSPC: System limit for number of file watchers reached, watch '/media/photo.jpg' @@ -68,7 +72,7 @@ In rare cases, the library watcher can hang, preventing Immich from starting up. ### Nightly job -There is an automatic scan job that is scheduled to run once a day. This job also cleans up any libraries stuck in deletion. +There is an automatic scan job that is scheduled to run once a day. This job also cleans up any libraries stuck in deletion. It is possible to trigger the cleanup by clicking "Scan all libraries" in the library managment page. ## Usage @@ -111,11 +115,10 @@ These actions must be performed by the Immich administrator. - Click on Administration -> Libraries - Click on Create External Library - Select which user owns the library, this can not be changed later +- Enter `/mnt/media/christmas-trip` then click Add +- Click on Save - Click the drop-down menu on the newly created library - Click on Rename Library and rename it to "Christmas Trip" -- Click Edit Import Paths -- Click on Add Path -- Enter `/mnt/media/christmas-trip` then click Add NOTE: We have to use the `/mnt/media/christmas-trip` path and not the `/mnt/nas/christmas-trip` path since all paths have to be what the Docker containers see. diff --git a/docs/docs/features/ml-hardware-acceleration.md b/docs/docs/features/ml-hardware-acceleration.md index 5af03f401d..a7229f1b9a 100644 --- a/docs/docs/features/ml-hardware-acceleration.md +++ b/docs/docs/features/ml-hardware-acceleration.md @@ -11,7 +11,7 @@ You do not need to redo any machine learning jobs after enabling hardware accele - ARM NN (Mali) - CUDA (NVIDIA GPUs with [compute capability](https://developer.nvidia.com/cuda-gpus) 5.2 or higher) -- OpenVINO (Intel discrete GPUs such as Iris Xe and Arc) +- OpenVINO (Intel GPUs such as Iris Xe and Arc) - RKNN (Rockchip) ## Limitations @@ -44,8 +44,9 @@ You do not need to redo any machine learning jobs after enabling hardware accele #### OpenVINO -- The server must have a discrete GPU, i.e. Iris Xe or Arc. Expect issues when attempting to use integrated graphics. +- Integrated GPUs are more likely to experience issues than discrete GPUs, especially for older processors or servers with low RAM. - Ensure the server's kernel version is new enough to use the device for hardware accceleration. +- Expect higher RAM usage when using OpenVINO compared to CPU processing. #### RKNN diff --git a/docs/docs/features/searching.md b/docs/docs/features/searching.md index 13547f6bac..eed5faa6fb 100644 --- a/docs/docs/features/searching.md +++ b/docs/docs/features/searching.md @@ -31,6 +31,7 @@ The filters smart search allows you to search by include: - Not in any album - Archived - Favorited + - Rating diff --git a/docs/docs/features/supported-formats.md b/docs/docs/features/supported-formats.md index bb6d00a100..e6fb2c8f00 100644 --- a/docs/docs/features/supported-formats.md +++ b/docs/docs/features/supported-formats.md @@ -8,22 +8,23 @@ For the full list, refer to the [Immich source code](https://github.com/immich-a ## Image formats -| Format | Extension(s) | Supported? | Notes | -| :-------- | :---------------------------- | :----------------: | :-------------- | -| `AVIF` | `.avif` | :white_check_mark: | | -| `BMP` | `.bmp` | :white_check_mark: | | -| `GIF` | `.gif` | :white_check_mark: | | -| `HEIC` | `.heic` | :white_check_mark: | | -| `HEIF` | `.heif` | :white_check_mark: | | -| `JPEG` | `.webp` `.jpg` `.jpe` `.insp` | :white_check_mark: | | -| `JPEG XL` | `.jxl` | :white_check_mark: | | -| `PNG` | `.webp` | :white_check_mark: | | -| `PSD` | `.psd` | :white_check_mark: | Adobe Photoshop | -| `RAW` | `.raw` | :white_check_mark: | | -| `RW2` | `.rw2` | :white_check_mark: | | -| `SVG` | `.svg` | :white_check_mark: | | -| `TIFF` | `.tif` `.tiff` | :white_check_mark: | | -| `WEBP` | `.webp` | :white_check_mark: | | +| Format | Extension(s) | Supported? | Notes | +| :---------- | :---------------------------- | :----------------: | :-------------- | +| `AVIF` | `.avif` | :white_check_mark: | | +| `BMP` | `.bmp` | :white_check_mark: | | +| `GIF` | `.gif` | :white_check_mark: | | +| `HEIC` | `.heic` | :white_check_mark: | | +| `HEIF` | `.heif` | :white_check_mark: | | +| `JPEG 2000` | `.jp2` | :white_check_mark: | | +| `JPEG` | `.webp` `.jpg` `.jpe` `.insp` | :white_check_mark: | | +| `JPEG XL` | `.jxl` | :white_check_mark: | | +| `PNG` | `.png` | :white_check_mark: | | +| `PSD` | `.psd` | :white_check_mark: | Adobe Photoshop | +| `RAW` | `.raw` | :white_check_mark: | | +| `RW2` | `.rw2` | :white_check_mark: | | +| `SVG` | `.svg` | :white_check_mark: | | +| `TIFF` | `.tif` `.tiff` | :white_check_mark: | | +| `WEBP` | `.webp` | :white_check_mark: | | ## Video formats diff --git a/docs/docs/guides/better-facial-clusters.md b/docs/docs/guides/better-facial-clusters.md new file mode 100644 index 0000000000..f4409b441c --- /dev/null +++ b/docs/docs/guides/better-facial-clusters.md @@ -0,0 +1,72 @@ +# Better Facial Recognition Clusters + +## Purpose + +This guide explains how to optimize facial recognition in systems with large image libraries. By following these steps, you'll achieve better clustering of faces, reducing the need for manual merging. + +--- + +## Important Notes + +- **Best Suited For:** Large image libraries after importing a significant number of images. +- **Warning:** This method deletes all previously assigned names. +- **Tip:** **Always take a [backup](/docs/administration/backup-and-restore#database) before proceeding!** + +--- + +## Step-by-Step Instructions + +### Objective + +To enhance face clustering and ensure the model effectively identifies faces using qualitative initial data. + +--- + +### Steps + +#### 1. Adjust Machine Learning Settings + +Navigate to: +**Admin → Administration → Settings → Machine Learning Settings** + +Make the following changes: + +- **Maximum recognition distance (Optional):** + Lower this value, e.g., to **0.4**, if the library contains people with similar facial features. +- **Minimum recognized faces:** + Set this to a **high value** (e.g., 20 For libraries with a large amount of assets (~100K+), and 10 for libraries with medium amount of assets (~40K+)). + > A high value ensures clusters only include faces that appear at least 20/`value` times in the library, improving the initial clustering process. + +--- + +#### 2. Run Reset Jobs + +Go to: +**Admin → Administration → Settings → Jobs** + +Perform the following: + +1. **FACIAL RECOGNITION → Reset** + +> These reset jobs rebuild the recognition model based on the new settings. + +--- + +#### 3. Refine Recognition with Lower Thresholds + +Once the reset jobs are complete, refine the recognition as follows: + +- **Step 1:** + Return to **Minimum recognized faces** in Machine Learning Settings and lower the value to **10** (In medium libraries we will lower the value from 10 to 5). + + > Run the job: **FACIAL RECOGNITION → MISSING Mode** + +- **Step 2:** + Lower the value again to **3**. + > Run the job: **FACIAL RECOGNITION → MISSING Mode** + +:::tip try different values +For certain libraries with a larger or smaller amount of assets, other settings will be better or worse. It is recommended to try different values **​​before assigning names** and see which settings work best for your library. +::: + +--- diff --git a/docs/docs/guides/custom-locations.md b/docs/docs/guides/custom-locations.md index 08f75b3e9d..ba5caf4b26 100644 --- a/docs/docs/guides/custom-locations.md +++ b/docs/docs/guides/custom-locations.md @@ -6,7 +6,7 @@ This guide explains how to store generated and raw files with docker's volume mo It is important to remember to update the backup settings after following the guide to back up the new backup paths if using automatic backup tools, especially `profile/`. ::: -In our `.env` file, we will define variables that will help us in the future when we want to move to a more advanced server +In our `.env` file, we will define the paths we want to use. Note that you don't have to define all of these: UPLOAD_LOCATION will be the base folder that files are stored in by default, with the other paths acting as overrides. ```diff title=".env" # You can find documentation for all the supported environment variables [here](/docs/install/environment-variables) @@ -21,7 +21,7 @@ In our `.env` file, we will define variables that will help us in the future whe ... ``` -After defining the locations of these files, we will edit the `docker-compose.yml` file accordingly and add the new variables to the `immich-server` container. +After defining the locations of these files, we will edit the `docker-compose.yml` file accordingly and add the new variables to the `immich-server` container. These paths are where the mount attaches inside of the container, so don't change those. ```diff title="docker-compose.yml" services: @@ -35,7 +35,8 @@ services: - /etc/localtime:/etc/localtime:ro ``` -Restart Immich to register the changes. +After making this change, you have to move the files over to the new folders to make sure Immich can find everything it needs. If you haven't uploaded anything important yet, you can also reset Immich entirely by deleting the database folder. +Then restart Immich to register the changes: ``` docker compose up -d diff --git a/docs/docs/guides/database-queries.md b/docs/docs/guides/database-queries.md index e71fa21c8b..89a4f07bc0 100644 --- a/docs/docs/guides/database-queries.md +++ b/docs/docs/guides/database-queries.md @@ -27,6 +27,14 @@ SELECT * FROM "assets" WHERE "originalPath" = 'upload/library/admin/2023/2023-09 SELECT * FROM "assets" WHERE "originalPath" LIKE 'upload/library/admin/2023/%'; ``` +```sql title="Find by ID" +SELECT * FROM "assets" WHERE "id" = '9f94e60f-65b6-47b7-ae44-a4df7b57f0e9'; +``` + +```sql title="Find by partial ID" +SELECT * FROM "assets" WHERE "id"::text LIKE '%ab431d3a%'; +``` + :::note You can calculate the checksum for a particular file by using the command `sha1sum `. ::: diff --git a/docs/docs/install/docker-compose.mdx b/docs/docs/install/docker-compose.mdx index 3593cf19ee..99a29397fa 100644 --- a/docs/docs/install/docker-compose.mdx +++ b/docs/docs/install/docker-compose.mdx @@ -37,7 +37,7 @@ You can alternatively download these two files from your browser and move them t - Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets. It should be a new directory on the server with enough free space. -- Consider changing `DB_PASSWORD` to a custom value. Postgres is not publically exposed, so this password is only used for local authentication. +- Consider changing `DB_PASSWORD` to a custom value. Postgres is not publicly exposed, so this password is only used for local authentication. To avoid issues with Docker parsing this value, it is best to use only the characters `A-Za-z0-9`. `pwgen` is a handy utility for this. - Set your timezone by uncommenting the `TZ=` line. - Populate custom database information if necessary. diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index 9e8abee1a8..e11547d240 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -11,7 +11,7 @@ Just restarting the containers does not replace the environment within the conta In order to recreate the container using docker compose, run `docker compose up -d`. In most cases docker will recognize that the `.env` file has changed and recreate the affected containers. -If this should not work, try running `docker compose up -d --force-recreate`. +If this does not work, try running `docker compose up -d --force-recreate`. ::: @@ -20,8 +20,8 @@ If this should not work, try running `docker compose up -d --force-recreate`. | Variable | Description | Default | Containers | | :----------------- | :------------------------------ | :-------: | :----------------------- | | `IMMICH_VERSION` | Image tags | `release` | server, machine learning | -| `UPLOAD_LOCATION` | Host Path for uploads | | server | -| `DB_DATA_LOCATION` | Host Path for Postgres database | | database | +| `UPLOAD_LOCATION` | Host path for uploads | | server | +| `DB_DATA_LOCATION` | Host path for Postgres database | | database | :::tip These environment variables are used by the `docker-compose.yml` file and do **NOT** affect the containers directly. @@ -33,15 +33,15 @@ These environment variables are used by the `docker-compose.yml` file and do **N | :---------------------------------- | :---------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- | | `TZ` | Timezone | \*1 | server | microservices | | `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices | -| `IMMICH_LOG_LEVEL` | Log Level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices | -| `IMMICH_MEDIA_LOCATION` | Media Location inside the container ⚠️**You probably shouldn't set this**\*2⚠️ | `./upload`\*3 | server | api, microservices | +| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices | +| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**\*2⚠️ | `./upload`\*3 | server | api, microservices | | `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices | | `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | | -| `CPU_CORES` | Amount of cores available to the immich server | auto-detected cpu core count | server | | +| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | | | `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api | | `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices | | `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices | -| `IMMICH_TRUSTED_PROXIES` | List of comma separated IPs set as trusted proxies | | server | api | +| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api | | `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/docs/administration/system-integrity) | | server | api, microservices | \*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`. @@ -50,7 +50,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N \*2: This path is where the Immich code looks for the files, which is internal to the docker container. Setting it to a path on your host will certainly break things, you should use the `UPLOAD_LOCATION` variable instead. \*3: With the default `WORKDIR` of `/usr/src/app`, this path will resolve to `/usr/src/app/upload`. -It only need to be set if the Immich deployment method is changing. +It only needs to be set if the Immich deployment method is changing. ## Workers @@ -75,12 +75,12 @@ Information on the current workers can be found [here](/docs/administration/jobs | Variable | Description | Default | Containers | | :---------------------------------- | :----------------------------------------------------------------------- | :----------: | :----------------------------- | | `DB_URL` | Database URL | | server | -| `DB_HOSTNAME` | Database Host | `database` | server | -| `DB_PORT` | Database Port | `5432` | server | -| `DB_USERNAME` | Database User | `postgres` | server, database\*1 | -| `DB_PASSWORD` | Database Password | `postgres` | server, database\*1 | -| `DB_DATABASE_NAME` | Database Name | `immich` | server, database\*1 | -| `DB_VECTOR_EXTENSION`\*2 | Database Vector Extension (one of [`pgvector`, `pgvecto.rs`]) | `pgvecto.rs` | server | +| `DB_HOSTNAME` | Database host | `database` | server | +| `DB_PORT` | Database port | `5432` | server | +| `DB_USERNAME` | Database user | `postgres` | server, database\*1 | +| `DB_PASSWORD` | Database password | `postgres` | server, database\*1 | +| `DB_DATABASE_NAME` | Database name | `immich` | server, database\*1 | +| `DB_VECTOR_EXTENSION`\*2 | Database vector extension (one of [`pgvector`, `pgvecto.rs`]) | `pgvecto.rs` | server | | `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server | \*1: The values of `DB_USERNAME`, `DB_PASSWORD`, and `DB_DATABASE_NAME` are passed to the Postgres container as the variables `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` in `docker-compose.yml`. @@ -103,18 +103,18 @@ When `DB_URL` is defined, the `DB_HOSTNAME`, `DB_PORT`, `DB_USERNAME`, `DB_PASSW | Variable | Description | Default | Containers | | :--------------- | :------------- | :-----: | :--------- | | `REDIS_URL` | Redis URL | | server | -| `REDIS_SOCKET` | Redis Socket | | server | -| `REDIS_HOSTNAME` | Redis Host | `redis` | server | -| `REDIS_PORT` | Redis Port | `6379` | server | -| `REDIS_USERNAME` | Redis Username | | server | -| `REDIS_PASSWORD` | Redis Password | | server | -| `REDIS_DBINDEX` | Redis DB Index | `0` | server | +| `REDIS_SOCKET` | Redis socket | | server | +| `REDIS_HOSTNAME` | Redis host | `redis` | server | +| `REDIS_PORT` | Redis port | `6379` | server | +| `REDIS_USERNAME` | Redis username | | server | +| `REDIS_PASSWORD` | Redis password | | server | +| `REDIS_DBINDEX` | Redis DB index | `0` | server | :::info All `REDIS_` variables must be provided to all Immich workers, including `api` and `microservices`. `REDIS_URL` must start with `ioredis://` and then include a `base64` encoded JSON string for the configuration. -More info can be found in the upstream [ioredis] documentation. +More information can be found in the upstream [ioredis] documentation. When `REDIS_URL` or `REDIS_SOCKET` are defined, the `REDIS_HOSTNAME`, `REDIS_PORT`, `REDIS_USERNAME`, `REDIS_PASSWORD`, and `REDIS_DBINDEX` variables are ignored. ::: @@ -168,6 +168,8 @@ Redis (Sentinel) URL example JSON before encoding: | `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning | | `MACHINE_LEARNING_DEVICE_IDS`\*4 | Device IDs to use in multi-GPU environments | `0` | machine learning | | `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning | +| `MACHINE_LEARNING_PING_TIMEOUT` | How long (ms) to wait for a PING response when checking if an ML server is available | `2000` | server | +| `MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME` | How long to ignore ML servers that are offline before trying again | `30000` | server | | `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning | | `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spinned up while inferencing. | `1` | machine learning | @@ -181,7 +183,11 @@ Redis (Sentinel) URL example JSON before encoding: :::info -Other machine learning parameters can be tuned from the admin UI. +While the `textual` model is the only one required for smart search, some users may experience slow first searches +due to backups triggering loading of the other models into memory, which blocks other requests until completed. +To avoid this, you can preload the other models (`visual`, `recognition`, and `detection`) if you have enough RAM to do so. + +Additional machine learning parameters can be tuned from the admin UI. ::: @@ -212,7 +218,7 @@ the `_FILE` variable should be set to the path of a file containing the variable details on how to use Docker Secrets in the Postgres image. \*2: See [this comment][docker-secrets-example] for an example of how -to use use a Docker secret for the password in the Redis container. +to use a Docker secret for the password in the Redis container. [tz-list]: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List [docker-secrets-example]: https://github.com/docker-library/redis/issues/46#issuecomment-335326234 diff --git a/docs/docs/install/script.md b/docs/docs/install/script.md index a515f2b628..93d1fb166c 100644 --- a/docs/docs/install/script.md +++ b/docs/docs/install/script.md @@ -27,7 +27,7 @@ The script will perform the following actions: 1. Download [docker-compose.yml](https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml), and the [.env](https://github.com/immich-app/immich/releases/latest/download/example.env) file from the main branch of the [repository](https://github.com/immich-app/immich). 2. Start the containers. -The web application will be available at `http://:2283`, and the server URL for the mobile app will be `http://:2283/api` +The web application and mobile app will be available at `http://:2283` The directory which is used to store the library files is `./immich-app` relative to the current directory. diff --git a/docs/docs/install/synology.md b/docs/docs/install/synology.md new file mode 100644 index 0000000000..cab80df999 --- /dev/null +++ b/docs/docs/install/synology.md @@ -0,0 +1,76 @@ +--- +sidebar_position: 85 +--- + +# Synology [Community] + +:::note +This is a community contribution and not officially supported by the Immich team, but included here for convenience. + +Community support can be found in the dedicated channel on the [Discord Server](https://discord.immich.app/). + +**Please report app issues to the corresponding [Github Repository](https://github.com/truenas/charts/tree/master/community/immich).** +::: + +Immich can easily be installed on a Synology NAS using Container Manager within DSM. If you have not installed Container Manager already, you can install it in the Packages Center. Refer to the [Container Manager docs](https://kb.synology.com/en-us/DSM/help/ContainerManager/docker_desc?version=7) for more information on using Container Manager. + +## Step 1 - Download the required files + +Create a directory of your choice (e.g. `./immich-app`) to house Immich. In general, it's a best practice to have all Docker-based applications running under the `./docker` directory, so in this case, your directory structure will look like `./docker/immich-app`. + +Now create a `./postgres` and `./library` directory as sub-directories of the `./docker/immich-app`. + +When you're all done, you should have the following: + +- `./docker/immich-app/postgres` +- `./docker/immich-app/library` + +Download [`docker-compose.yml`](https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml) and [`example.env`](https://github.com/immich-app/immich/releases/latest/download/example.env) to your computer. Upload the files to the `./docker/immich-app` directory. + +## Step 2 - Populate the .env file with custom values + +Follow [Step 2 in Docker Compose](./docker-compose#step-2---populate-the-env-file-with-custom-values) for instructions on customizing the `.env` file, and then return back to this guide to continue. + +## Step 3 - Create a new project in Container Manager + +Open Container Manager, and select the "**Project**" action on the left navigation bar and then click "**Create**". +![Create Project](../../static/img/synology-container-manager-create-project.png) + +In the settings of your new project, set "**Project name**" to a name you'll remember, such as _immich-app_. When setting the "**Path**", select the `./docker/immich-app` directory you created earlier. Doing so will prompt a message to use the existing `docker-compose.yml` already present in the directory for your project. Click "**OK**" to continue. + +![Set Path](../../static/img/synology-container-manager-set-path.png) + +The following screen will give you the option to further customize your `docker-compose.yml` file, giving you a warning regarding the `start_interval` property. Under the `healthcheck` heading, remove the `start_interval: 30s` completely and click "**Next**". + +![start interval](../../static/img/synology-container-manager-customize-docker-compose.png) + +Skip the section asking to set-up a portal for Web Station, and then complete the wizard which will build and start the containers for your project. + +Once your containers are successfully running, navigate to the "**Container**" section of Container Manager, right-click on the "**immich-server**" container, and choose the "**Details**". + +Scroll to the bottom of the "**Details**" section, and find the `IP Address` of the container, located in the `Network` section. Take note of the container's IP address as you will need it for **Step 4**. + +![Container Details](../../static/img/synology-container-manager-container-details.png) + +## Step 4 - Configure Firewall Settings + +Once your project completes the build process, your containers will start. In order to be able to access Immich from your browser, you need to configure the firewall settings for your Synology NAS. + +Open "**Control Panel**" on your Synology NAS, and select "**Security**". Navigate to "**Firewall**" + +![Firewall rules](../../static/img/synology-firewall-rules.png) + +Click "**Edit Rules**" and add the following firewall rules: + +- Add a "**Source IP**" rule for the IP address of your container that you obtained in Step 3 above +- Add a "**Ports**" rule for the port specified in the `docker-compose.yml`, which should be `2283` + +## Next Steps + +Read the [Post Installation](/docs/install/post-install.mdx) steps or setup optional features below. + +### Setting up optional features + +- [External Libraries](/docs/features/libraries.md): Adding your existing photo library to Immich +- [Hardware Transcoding](/docs/features/hardware-transcoding.md): Speeding up video transcoding +- [Hardware-Accelerated Machine Learning](/docs/features/ml-hardware-acceleration.md): Speeding up various machine learning tasks in Immich diff --git a/docs/docs/install/truenas.md b/docs/docs/install/truenas.md index c56eaf21b2..31b007a47d 100644 --- a/docs/docs/install/truenas.md +++ b/docs/docs/install/truenas.md @@ -41,7 +41,7 @@ className="border rounded-xl" :::info Permissions The **pgData** dataset must be owned by the user `netdata` (UID 999) for postgres to start. The other datasets must be owned by the user `root` (UID 0) or a group that includes the user `root` (UID 0) for immich to have the necessary permissions. -If the **library** dataset uses ACL it must have [ACL mode](https://www.truenas.com/docs/core/coretutorials/storage/pools/permissions/#access-control-lists) set to `Passthrough` if you plan on using a [storage template](/docs/administration/storage-template.mdx) and the dataset is configured for network sharing (its ACL type is set to `SMB/NFSv4`). When the template is applied and files need to be moved from **upload** to **library**, immich performs `chmod` internally and needs to be allowed to execute the command. [More info.](https://github.com/immich-app/immich/pull/13017) +If the **library** dataset uses ACL it must have [ACL mode](https://www.truenas.com/docs/core/coretutorials/storage/pools/permissions/#access-control-lists) set to `Passthrough` if you plan on using a [storage template](/docs/administration/storage-template.mdx) and the dataset is configured for network sharing (its ACL type is set to `SMB/NFSv4`). When the template is applied and files need to be moved from **upload** to **library**, Immich performs `chmod` internally and needs to be allowed to execute the command. [More info.](https://github.com/immich-app/immich/pull/13017) ::: ## Installing the Immich Application @@ -160,6 +160,10 @@ The image above has example values. ### Additional Storage [(External Libraries)](/docs/features/libraries) +:::danger Advanced Users Only +This feature should only be used by advanced users. If this is your first time installing Immich, then DO NOT mount an external library until you have a working setup. Also, your mount path MUST be something unique and should NOT be your library or upload location or a Linux directory like `/lib`. The picture below shows a valid example. +::: + You may configure [External Libraries](/docs/features/libraries) by mounting them using **Additional Storage**. -The **Mount Path** is the loaction you will need to copy and paste into the External Library settings within Immich. +The **Mount Path** is the location you will need to copy and paste into the External Library settings within Immich. The **Host Path** is the location on the TrueNAS SCALE server where your external library is located. @@ -194,7 +198,7 @@ The **CPU** value was specified in a different format with a default of `4000m` The **Memory** value was specified in a different format with a default of `8Gi` which is 8 GiB of RAM. The value was specified in bytes or a number with a measurement suffix. Examples: `129M`, `123Mi`, `1000000000` ::: -Enable **GPU Configuration** options if you have a GPU that you will use for [Hardware Transcoding](/docs/features/hardware-transcoding) and/or [Hardware-Accelerated Machine Learning](/docs/features/ml-hardware-acceleration.md). More info: [GPU Passtrough Docs for TrueNAS Apps](https://www.truenas.com/docs/truenasapps/#gpu-passthrough) +Enable **GPU Configuration** options if you have a GPU that you will use for [Hardware Transcoding](/docs/features/hardware-transcoding) and/or [Hardware-Accelerated Machine Learning](/docs/features/ml-hardware-acceleration.md). More info: [GPU Passthrough Docs for TrueNAS Apps](https://www.truenas.com/docs/truenasapps/#gpu-passthrough) ### Install diff --git a/docs/docs/install/unraid.md b/docs/docs/install/unraid.md index d6dde4e8c5..731f53bb00 100644 --- a/docs/docs/install/unraid.md +++ b/docs/docs/install/unraid.md @@ -72,12 +72,12 @@ alt="Select Plugins > Compose.Manager > Add New Stack > Label it Immich" -5. Click "**Save Changes**", you will be promoted to edit stack UI labels, just leave this blank and click "**Ok**" +5. Click "**Save Changes**", you will be prompted to edit stack UI labels, just leave this blank and click "**Ok**" 6. Select the cog ⚙️ next to Immich, click "**Edit Stack**", then click "**Env File**" 7. Paste the entire contents of the [Immich example.env](https://github.com/immich-app/immich/releases/latest/download/example.env) file into the Unraid editor, then **before saving** edit the following: - `UPLOAD_LOCATION`: Create a folder in your Images Unraid share and place the **absolute** location here > For example my _"images"_ share has a folder within it called _"immich"_. If I browse to this directory in the terminal and type `pwd` the output is `/mnt/user/images/immich`. This is the exact value I need to enter as my `UPLOAD_LOCATION` - - `DB_DATA_LOCATION`: Change this to use an Unraid share (preferably a cache pool, e.g. `/mnt/user/appdata`). If left at default it will try to use Unraid's `/boot/config/plugins/compose.manager/projects/[stack_name]/postgres` folder which it doesn't have permissions to, resulting in this container continuously restarting. + - `DB_DATA_LOCATION`: Change this to use an Unraid share (preferably a cache pool, e.g. `/mnt/user/appdata/postgresql/data`). This uses the `appdata` share. Do also create the `postgresql` folder, by running `mkdir /mnt/user/{share_location}/postgresql/data`. If left at default it will try to use Unraid's `/boot/config/plugins/compose.manager/projects/[stack_name]/postgres` folder which it doesn't have permissions to, resulting in this container continuously restarting. :2283/api` +Login to the mobile app with the server endpoint URL at `http://:2283` diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 16d654b46b..7166611a2e 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -110,9 +110,9 @@ const config = { label: 'API', }, { - to: '/blog', + href: 'https://immich.store', position: 'right', - label: 'Blog', + label: 'Merch', }, { href: 'https://github.com/immich-app/immich', diff --git a/docs/package-lock.json b/docs/package-lock.json index 898388fb65..37e6a4fe65 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -28,6 +28,8 @@ }, "devDependencies": { "@docusaurus/module-type-aliases": "~3.7.0", + "@docusaurus/tsconfig": "^3.7.0", + "@docusaurus/types": "^3.7.0", "prettier": "^3.2.4", "typescript": "^5.1.6" }, @@ -3698,6 +3700,13 @@ "node": ">=18.0" } }, + "node_modules/@docusaurus/tsconfig": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@docusaurus/tsconfig/-/tsconfig-3.7.0.tgz", + "integrity": "sha512-vRsyj3yUZCjscgfgcFYjIsTcAru/4h4YH2/XAE8Rs7wWdnng98PgWKvP5ovVc4rmRpRg2WChVW0uOy2xHDvDBQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@docusaurus/types": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.7.0.tgz", @@ -14061,9 +14070,9 @@ } }, "node_modules/postcss": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz", - "integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==", + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", "funding": [ { "type": "opencollective", @@ -15725,9 +15734,9 @@ } }, "node_modules/prettier": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", - "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.2.tgz", + "integrity": "sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg==", "dev": true, "license": "MIT", "bin": { @@ -18368,9 +18377,9 @@ } }, "node_modules/typescript": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", - "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/docs/package.json b/docs/package.json index f7f9f008c2..27a7651f78 100644 --- a/docs/package.json +++ b/docs/package.json @@ -36,6 +36,8 @@ }, "devDependencies": { "@docusaurus/module-type-aliases": "~3.7.0", + "@docusaurus/tsconfig": "^3.7.0", + "@docusaurus/types": "^3.7.0", "prettier": "^3.2.4", "typescript": "^5.1.6" }, @@ -55,6 +57,6 @@ "node": ">=20" }, "volta": { - "node": "22.13.1" + "node": "22.14.0" } } diff --git a/docs/src/components/community-guides.tsx b/docs/src/components/community-guides.tsx index 17fe562317..f5331a9163 100644 --- a/docs/src/components/community-guides.tsx +++ b/docs/src/components/community-guides.tsx @@ -53,6 +53,11 @@ const guides: CommunityGuidesProps[] = [ description: 'How to configure an existing fail2ban installation to block incorrect login attempts.', url: 'https://github.com/immich-app/immich/discussions/3243#discussioncomment-6681948', }, + { + title: 'Immich remote access with NordVPN Meshnet', + description: 'Access Immich with an end-to-end encrypted connection.', + url: 'https://meshnet.nordvpn.com/how-to/remote-files-media-access/immich-remote-access', + }, ]; function CommunityGuide({ title, description, url }: CommunityGuidesProps): JSX.Element { diff --git a/docs/src/components/community-projects.tsx b/docs/src/components/community-projects.tsx index dc64214d1e..b30544d461 100644 --- a/docs/src/components/community-projects.tsx +++ b/docs/src/components/community-projects.tsx @@ -99,6 +99,11 @@ const projects: CommunityProjectProps[] = [ description: 'Downloads a configurable number of random photos based on people or album ID.', url: 'https://github.com/jon6fingrs/immich-dl', }, + { + title: 'Immich Upload Optimizer', + description: 'Automatically optimize files uploaded to Immich in order to save storage space', + url: 'https://github.com/miguelangel-nubla/immich-upload-optimizer', + }, ]; function CommunityProject({ title, description, url }: CommunityProjectProps): JSX.Element { diff --git a/docs/src/components/svg-paths.ts b/docs/src/components/svg-paths.ts index 112ed1d70f..0903392307 100644 --- a/docs/src/components/svg-paths.ts +++ b/docs/src/components/svg-paths.ts @@ -1,2 +1,3 @@ export const discordPath = - 'M 9.1367188 3.8691406 C 9.1217187 3.8691406 9.1067969 3.8700938 9.0917969 3.8710938 C 8.9647969 3.8810937 5.9534375 4.1403594 4.0234375 5.6933594 C 3.0154375 6.6253594 1 12.073203 1 16.783203 C 1 16.866203 1.0215 16.946531 1.0625 17.019531 C 2.4535 19.462531 6.2473281 20.102859 7.1113281 20.130859 L 7.1269531 20.130859 C 7.2799531 20.130859 7.4236719 20.057594 7.5136719 19.933594 L 8.3886719 18.732422 C 6.0296719 18.122422 4.8248594 17.086391 4.7558594 17.025391 C 4.5578594 16.850391 4.5378906 16.549563 4.7128906 16.351562 C 4.8068906 16.244563 4.9383125 16.189453 5.0703125 16.189453 C 5.1823125 16.189453 5.2957188 16.228594 5.3867188 16.308594 C 5.4157187 16.334594 7.6340469 18.216797 11.998047 18.216797 C 16.370047 18.216797 18.589328 16.325641 18.611328 16.306641 C 18.702328 16.227641 18.815734 16.189453 18.927734 16.189453 C 19.059734 16.189453 19.190156 16.243562 19.285156 16.351562 C 19.459156 16.549563 19.441141 16.851391 19.244141 17.025391 C 19.174141 17.087391 17.968375 18.120469 15.609375 18.730469 L 16.484375 19.933594 C 16.574375 20.057594 16.718094 20.130859 16.871094 20.130859 L 16.886719 20.130859 C 17.751719 20.103859 21.5465 19.463531 22.9375 17.019531 C 22.9785 16.947531 23 16.866203 23 16.783203 C 23 12.073203 20.984172 6.624875 19.951172 5.671875 C 18.047172 4.140875 15.036203 3.8820937 14.908203 3.8710938 C 14.895203 3.8700938 14.880188 3.8691406 14.867188 3.8691406 C 14.681188 3.8691406 14.510594 3.9793906 14.433594 4.1503906 C 14.427594 4.1623906 14.362062 4.3138281 14.289062 4.5488281 C 15.548063 4.7608281 17.094141 5.1895937 18.494141 6.0585938 C 18.718141 6.1975938 18.787437 6.4917969 18.648438 6.7167969 C 18.558438 6.8627969 18.402188 6.9433594 18.242188 6.9433594 C 18.156188 6.9433594 18.069234 6.9200937 17.990234 6.8710938 C 15.584234 5.3800938 12.578 5.3046875 12 5.3046875 C 11.422 5.3046875 8.4157187 5.3810469 6.0117188 6.8730469 C 5.9327188 6.9210469 5.8457656 6.9433594 5.7597656 6.9433594 C 5.5997656 6.9433594 5.4425625 6.86475 5.3515625 6.71875 C 5.2115625 6.49375 5.2818594 6.1985938 5.5058594 6.0585938 C 6.9058594 5.1905937 8.4528906 4.7627812 9.7128906 4.5507812 C 9.6388906 4.3147813 9.5714062 4.1643437 9.5664062 4.1523438 C 9.4894063 3.9813438 9.3217188 3.8691406 9.1367188 3.8691406 z M 12 7.3046875 C 12.296 7.3046875 14.950594 7.3403125 16.933594 8.5703125 C 17.326594 8.8143125 17.777234 8.9453125 18.240234 8.9453125 C 18.633234 8.9453125 19.010656 8.8555 19.347656 8.6875 C 19.964656 10.2405 20.690828 12.686219 20.923828 15.199219 C 20.883828 15.143219 20.840922 15.089109 20.794922 15.037109 C 20.324922 14.498109 19.644687 14.191406 18.929688 14.191406 C 18.332687 14.191406 17.754078 14.405437 17.330078 14.773438 C 17.257078 14.832437 15.505 16.21875 12 16.21875 C 8.496 16.21875 6.7450313 14.834687 6.7070312 14.804688 C 6.2540312 14.407687 5.6742656 14.189453 5.0722656 14.189453 C 4.3612656 14.189453 3.6838438 14.494391 3.2148438 15.025391 C 3.1658438 15.080391 3.1201719 15.138266 3.0761719 15.197266 C 3.3091719 12.686266 4.0344375 10.235594 4.6484375 8.6835938 C 4.9864375 8.8525938 5.3657656 8.9433594 5.7597656 8.9433594 C 6.2217656 8.9433594 6.6724531 8.8143125 7.0644531 8.5703125 C 9.0494531 7.3393125 11.704 7.3046875 12 7.3046875 z M 8.890625 10.044922 C 7.966625 10.044922 7.2167969 10.901031 7.2167969 11.957031 C 7.2167969 13.013031 7.965625 13.869141 8.890625 13.869141 C 9.815625 13.869141 10.564453 13.013031 10.564453 11.957031 C 10.564453 10.900031 9.815625 10.044922 8.890625 10.044922 z M 15.109375 10.044922 C 14.185375 10.044922 13.435547 10.901031 13.435547 11.957031 C 13.435547 13.013031 14.184375 13.869141 15.109375 13.869141 C 16.034375 13.869141 16.783203 13.013031 16.783203 11.957031 C 16.783203 10.900031 16.033375 10.044922 15.109375 10.044922 z'; + 'M81.15,0c-1.2376,2.1973-2.3489,4.4704-3.3591,6.794-9.5975-1.4396-19.3718-1.4396-28.9945,0-.985-2.3236-2.1216-4.5967-3.3591-6.794-9.0166,1.5407-17.8059,4.2431-26.1405,8.0568C2.779,32.5304-1.6914,56.3725.5312,79.8863c9.6732,7.1476,20.5083,12.603,32.0505,16.0884,2.6014-3.4854,4.8998-7.1981,6.8698-11.0623-3.738-1.3891-7.3497-3.1318-10.8098-5.1523.9092-.6567,1.7932-1.3386,2.6519-1.9953,20.281,9.547,43.7696,9.547,64.0758,0,.8587.7072,1.7427,1.3891,2.6519,1.9953-3.4601,2.0457-7.0718,3.7632-10.835,5.1776,1.97,3.8642,4.2683,7.5769,6.8698,11.0623,11.5419-3.4854,22.3769-8.9156,32.0509-16.0631,2.626-27.2771-4.496-50.9172-18.817-71.8548C98.9811,4.2684,90.1918,1.5659,81.1752.0505l-.0252-.0505ZM42.2802,65.4144c-6.2383,0-11.4159-5.6575-11.4159-12.6535s4.9755-12.6788,11.3907-12.6788,11.5169,5.708,11.4159,12.6788c-.101,6.9708-5.026,12.6535-11.3907,12.6535ZM84.3576,65.4144c-6.2637,0-11.3907-5.6575-11.3907-12.6535s4.9755-12.6788,11.3907-12.6788,11.4917,5.708,11.3906,12.6788c-.101,6.9708-5.026,12.6535-11.3906,12.6535Z'; +export const discordViewBox = '0 0 126.644 96'; diff --git a/docs/src/components/version-switcher.tsx b/docs/src/components/version-switcher.tsx index b89a65c6e4..5cb23891aa 100644 --- a/docs/src/components/version-switcher.tsx +++ b/docs/src/components/version-switcher.tsx @@ -4,7 +4,7 @@ import React, { useEffect, useState } from 'react'; export default function VersionSwitcher(): JSX.Element { const [versions, setVersions] = useState([]); - const [label, setLabel] = useState('Versions'); + const [activeLabel, setLabel] = useState('Versions'); const windowSize = useWindowSize(); @@ -24,10 +24,13 @@ export default function VersionSwitcher(): JSX.Element { { label: 'Next', url: 'https://main.preview.immich.app' }, { label: 'Latest', url: 'https://immich.app' }, ...archiveVersions, - ]; + ].map(({ label, url }) => ({ + label, + url: new URL(url), + })); setVersions(allVersions); - const activeVersion = allVersions.find((version) => new URL(version.url).origin === window.location.origin); + const activeVersion = allVersions.find((version) => version.url.origin === window.location.origin); if (activeVersion) { setLabel(activeVersion.label); } @@ -44,13 +47,14 @@ export default function VersionSwitcher(): JSX.Element { return ( versions.length > 0 && ( ({ label, - to: url, + to: new URL(location.pathname + location.search + location.hash, url).href, target: '_self', + className: label === activeLabel ? 'dropdown__link--active menu__link--active' : '', // workaround because React Router `` only supports using URL path for checking if active: https://v5.reactrouter.com/web/api/NavLink/isactive-func }))} /> ) diff --git a/docs/src/css/custom.css b/docs/src/css/custom.css index dc3ff4e9ef..fd3f199ce8 100644 --- a/docs/src/css/custom.css +++ b/docs/src/css/custom.css @@ -75,6 +75,11 @@ div[class^='announcementBar_'] { font-weight: 500; } +/* workaround for version switcher PR 15894 */ +div[class*='navbar__items'] > li:has(a[class*='version-switcher-34ab39']) { + display: none; +} + code { font-weight: 600; } diff --git a/docs/src/pages/index.tsx b/docs/src/pages/index.tsx index b3cf10b810..d4325eb006 100644 --- a/docs/src/pages/index.tsx +++ b/docs/src/pages/index.tsx @@ -2,7 +2,7 @@ import React from 'react'; import Link from '@docusaurus/Link'; import Layout from '@theme/Layout'; import { useColorMode } from '@docusaurus/theme-common'; -import { discordPath } from '@site/src/components/svg-paths'; +import { discordPath, discordViewBox } from '@site/src/components/svg-paths'; import Icon from '@mdi/react'; function HomepageHeader() { const { isDarkTheme } = useColorMode(); @@ -50,10 +50,21 @@ function HomepageHeader() { > Demo + + + Buy Merch +

- + Join our Discord
=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", + "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", "cpu": [ "arm" ], @@ -391,13 +394,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", + "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", "cpu": [ "arm64" ], @@ -408,13 +411,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", + "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", "cpu": [ "x64" ], @@ -425,13 +428,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", + "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", "cpu": [ "arm64" ], @@ -442,13 +445,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", + "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", "cpu": [ "x64" ], @@ -459,13 +462,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", + "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", "cpu": [ "arm64" ], @@ -476,13 +479,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", + "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", "cpu": [ "x64" ], @@ -493,13 +496,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", + "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", "cpu": [ "arm" ], @@ -510,13 +513,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", + "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", "cpu": [ "arm64" ], @@ -527,13 +530,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", + "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", "cpu": [ "ia32" ], @@ -544,13 +547,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", + "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", "cpu": [ "loong64" ], @@ -561,13 +564,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", + "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", "cpu": [ "mips64el" ], @@ -578,13 +581,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", + "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", "cpu": [ "ppc64" ], @@ -595,13 +598,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", + "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", "cpu": [ "riscv64" ], @@ -612,13 +615,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", + "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", "cpu": [ "s390x" ], @@ -629,13 +632,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", + "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", "cpu": [ "x64" ], @@ -646,13 +649,30 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", + "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", + "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", "cpu": [ "x64" ], @@ -663,13 +683,30 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", + "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", + "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", "cpu": [ "x64" ], @@ -680,13 +717,13 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", + "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", "cpu": [ "x64" ], @@ -697,13 +734,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", + "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", "cpu": [ "arm64" ], @@ -714,13 +751,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", + "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", "cpu": [ "ia32" ], @@ -731,13 +768,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", + "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", "cpu": [ "x64" ], @@ -748,7 +785,7 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -777,13 +814,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.1.tgz", - "integrity": "sha512-fo6Mtm5mWyKjA/Chy1BYTdn5mGJoDNjC7C64ug20ADsRDGrA85bN3uK3MaKbeRkRuuIEAR5N33Jr1pbm411/PA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", + "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.5", + "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -792,9 +829,9 @@ } }, "node_modules/@eslint/core": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.10.0.tgz", - "integrity": "sha512-gFHJ+xBOo4G3WRlR1e/3G8A6/KZAH6zcE/hkLRCZTi/B9avAG365QhFA8uOGzTMqgTghpn7/fSnscW++dpMSAw==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", + "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -805,9 +842,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz", - "integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.0.tgz", + "integrity": "sha512-yaVPAiNAalnCZedKLdR21GOGILMLKPyqSLWaAjQFvYA2i/ciDi8ArYVr69Anohb6cH2Ukhqti4aFnYyPm8wdwQ==", "dev": true, "license": "MIT", "dependencies": { @@ -842,9 +879,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.18.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.18.0.tgz", - "integrity": "sha512-fK6L7rxcq6/z+AaQMtiFTkvbHkBLNlwyRxHpKawP0x3u9+NC6MQTnFW+AdpwC6gfHTW0051cokQgtTN2FqlxQA==", + "version": "9.21.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.21.0.tgz", + "integrity": "sha512-BqStZ3HX8Yz6LvsF5ByXYrtigrV5AXADWLAGc7PH/1SxOb7/FIYYMszZZWiUou/GB9P2lXWk2SV4d+Z8h0nknw==", "dev": true, "license": "MIT", "engines": { @@ -852,9 +889,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.5.tgz", - "integrity": "sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -862,13 +899,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.5.tgz", - "integrity": "sha512-lB05FkqEdUg2AA0xEbUz0SnkXT1LcCTa438W4IWTUh4hdOnVbQyOJ81OrDXsJk/LSiJHubgGEFoR5EHq1NsH1A==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.7.tgz", + "integrity": "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.10.0", + "@eslint/core": "^0.12.0", "levn": "^0.4.1" }, "engines": { @@ -927,9 +964,9 @@ } }, "node_modules/@humanwhocodes/retry": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz", - "integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", + "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1211,13 +1248,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.49.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz", - "integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==", + "version": "1.50.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.50.1.tgz", + "integrity": "sha512-Jii3aBg+CEDpgnuDxEp/h7BimHcUTDlpEtce89xEumlJ5ef2hqepZ+PWp1DDpYC/VO9fmWVI1IlEaoI5fK9FXQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.49.1" + "playwright": "1.50.1" }, "bin": { "playwright": "cli.js" @@ -1227,9 +1264,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.27.3.tgz", - "integrity": "sha512-EzxVSkIvCFxUd4Mgm4xR9YXrcp976qVaHnqom/Tgm+vU79k4vV4eYTjmRvGfeoW8m9LVcsAy/lGjcgVegKEhLQ==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.9.tgz", + "integrity": "sha512-qZdlImWXur0CFakn2BJ2znJOdqYZKiedEPEVNTBrpfPjc/YuTGcaYZcdmNFTkUj3DU0ZM/AElcM8Ybww3xVLzA==", "cpu": [ "arm" ], @@ -1241,9 +1278,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.27.3.tgz", - "integrity": "sha512-LJc5pDf1wjlt9o/Giaw9Ofl+k/vLUaYsE2zeQGH85giX2F+wn/Cg8b3c5CDP3qmVmeO5NzwVUzQQxwZvC2eQKw==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.9.tgz", + "integrity": "sha512-4KW7P53h6HtJf5Y608T1ISKvNIYLWRKMvfnG0c44M6In4DQVU58HZFEVhWINDZKp7FZps98G3gxwC1sb0wXUUg==", "cpu": [ "arm64" ], @@ -1255,9 +1292,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.27.3.tgz", - "integrity": "sha512-OuRysZ1Mt7wpWJ+aYKblVbJWtVn3Cy52h8nLuNSzTqSesYw1EuN6wKp5NW/4eSre3mp12gqFRXOKTcN3AI3LqA==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.9.tgz", + "integrity": "sha512-0CY3/K54slrzLDjOA7TOjN1NuLKERBgk9nY5V34mhmuu673YNb+7ghaDUs6N0ujXR7fz5XaS5Aa6d2TNxZd0OQ==", "cpu": [ "arm64" ], @@ -1269,9 +1306,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.27.3.tgz", - "integrity": "sha512-xW//zjJMlJs2sOrCmXdB4d0uiilZsOdlGQIC/jjmMWT47lkLLoB1nsNhPUcnoqyi5YR6I4h+FjBpILxbEy8JRg==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.9.tgz", + "integrity": "sha512-eOojSEAi/acnsJVYRxnMkPFqcxSMFfrw7r2iD9Q32SGkb/Q9FpUY1UlAu1DH9T7j++gZ0lHjnm4OyH2vCI7l7Q==", "cpu": [ "x64" ], @@ -1283,9 +1320,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.27.3.tgz", - "integrity": "sha512-58E0tIcwZ+12nK1WiLzHOD8I0d0kdrY/+o7yFVPRHuVGY3twBwzwDdTIBGRxLmyjciMYl1B/U515GJy+yn46qw==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.9.tgz", + "integrity": "sha512-2lzjQPJbN5UnHm7bHIUKFMulGTQwdvOkouJDpPysJS+QFBGDJqcfh+CxxtG23Ik/9tEvnebQiylYoazFMAgrYw==", "cpu": [ "arm64" ], @@ -1297,9 +1334,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.27.3.tgz", - "integrity": "sha512-78fohrpcVwTLxg1ZzBMlwEimoAJmY6B+5TsyAZ3Vok7YabRBUvjYTsRXPTjGEvv/mfgVBepbW28OlMEz4w8wGA==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.9.tgz", + "integrity": "sha512-SLl0hi2Ah2H7xQYd6Qaiu01kFPzQ+hqvdYSoOtHYg/zCIFs6t8sV95kaoqjzjFwuYQLtOI0RZre/Ke0nPaQV+g==", "cpu": [ "x64" ], @@ -1311,9 +1348,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.27.3.tgz", - "integrity": "sha512-h2Ay79YFXyQi+QZKo3ISZDyKaVD7uUvukEHTOft7kh00WF9mxAaxZsNs3o/eukbeKuH35jBvQqrT61fzKfAB/Q==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.9.tgz", + "integrity": "sha512-88I+D3TeKItrw+Y/2ud4Tw0+3CxQ2kLgu3QvrogZ0OfkmX/DEppehus7L3TS2Q4lpB+hYyxhkQiYPJ6Mf5/dPg==", "cpu": [ "arm" ], @@ -1325,9 +1362,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.27.3.tgz", - "integrity": "sha512-Sv2GWmrJfRY57urktVLQ0VKZjNZGogVtASAgosDZ1aUB+ykPxSi3X1nWORL5Jk0sTIIwQiPH7iE3BMi9zGWfkg==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.9.tgz", + "integrity": "sha512-3qyfWljSFHi9zH0KgtEPG4cBXHDFhwD8kwg6xLfHQ0IWuH9crp005GfoUUh/6w9/FWGBwEHg3lxK1iHRN1MFlA==", "cpu": [ "arm" ], @@ -1339,9 +1376,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.27.3.tgz", - "integrity": "sha512-FPoJBLsPW2bDNWjSrwNuTPUt30VnfM8GPGRoLCYKZpPx0xiIEdFip3dH6CqgoT0RnoGXptaNziM0WlKgBc+OWQ==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.9.tgz", + "integrity": "sha512-6TZjPHjKZUQKmVKMUowF3ewHxctrRR09eYyvT5eFv8w/fXarEra83A2mHTVJLA5xU91aCNOUnM+DWFMSbQ0Nxw==", "cpu": [ "arm64" ], @@ -1353,9 +1390,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.27.3.tgz", - "integrity": "sha512-TKxiOvBorYq4sUpA0JT+Fkh+l+G9DScnG5Dqx7wiiqVMiRSkzTclP35pE6eQQYjP4Gc8yEkJGea6rz4qyWhp3g==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.9.tgz", + "integrity": "sha512-LD2fytxZJZ6xzOKnMbIpgzFOuIKlxVOpiMAXawsAZ2mHBPEYOnLRK5TTEsID6z4eM23DuO88X0Tq1mErHMVq0A==", "cpu": [ "arm64" ], @@ -1366,10 +1403,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.9.tgz", + "integrity": "sha512-dRAgTfDsn0TE0HI6cmo13hemKpVHOEyeciGtvlBTkpx/F65kTvShtY/EVyZEIfxFkV5JJTuQ9tP5HGBS0hfxIg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.27.3.tgz", - "integrity": "sha512-v2M/mPvVUKVOKITa0oCFksnQQ/TqGrT+yD0184/cWHIu0LoIuYHwox0Pm3ccXEz8cEQDLk6FPKd1CCm+PlsISw==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.9.tgz", + "integrity": "sha512-PHcNOAEhkoMSQtMf+rJofwisZqaU8iQ8EaSps58f5HYll9EAY5BSErCZ8qBDMVbq88h4UxaNPlbrKqfWP8RfJA==", "cpu": [ "ppc64" ], @@ -1381,9 +1432,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.27.3.tgz", - "integrity": "sha512-LdrI4Yocb1a/tFVkzmOE5WyYRgEBOyEhWYJe4gsDWDiwnjYKjNs7PS6SGlTDB7maOHF4kxevsuNBl2iOcj3b4A==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.9.tgz", + "integrity": "sha512-Z2i0Uy5G96KBYKjeQFKbbsB54xFOL5/y1P5wNBsbXB8yE+At3oh0DVMjQVzCJRJSfReiB2tX8T6HUFZ2k8iaKg==", "cpu": [ "riscv64" ], @@ -1395,9 +1446,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.27.3.tgz", - "integrity": "sha512-d4wVu6SXij/jyiwPvI6C4KxdGzuZOvJ6y9VfrcleHTwo68fl8vZC5ZYHsCVPUi4tndCfMlFniWgwonQ5CUpQcA==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.9.tgz", + "integrity": "sha512-U+5SwTMoeYXoDzJX5dhDTxRltSrIax8KWwfaaYcynuJw8mT33W7oOgz0a+AaXtGuvhzTr2tVKh5UO8GVANTxyQ==", "cpu": [ "s390x" ], @@ -1409,9 +1460,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.27.3.tgz", - "integrity": "sha512-/6bn6pp1fsCGEY5n3yajmzZQAh+mW4QPItbiWxs69zskBzJuheb3tNynEjL+mKOsUSFK11X4LYF2BwwXnzWleA==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.9.tgz", + "integrity": "sha512-FwBHNSOjUTQLP4MG7y6rR6qbGw4MFeQnIBrMe161QGaQoBQLqSUEKlHIiVgF3g/mb3lxlxzJOpIBhaP+C+KP2A==", "cpu": [ "x64" ], @@ -1423,9 +1474,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.27.3.tgz", - "integrity": "sha512-nBXOfJds8OzUT1qUreT/en3eyOXd2EH5b0wr2bVB5999qHdGKkzGzIyKYaKj02lXk6wpN71ltLIaQpu58YFBoQ==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.9.tgz", + "integrity": "sha512-cYRpV4650z2I3/s6+5/LONkjIz8MBeqrk+vPXV10ORBnshpn8S32bPqQ2Utv39jCiDcO2eJTuSlPXpnvmaIgRA==", "cpu": [ "x64" ], @@ -1437,9 +1488,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.27.3.tgz", - "integrity": "sha512-ogfbEVQgIZOz5WPWXF2HVb6En+kWzScuxJo/WdQTqEgeyGkaa2ui5sQav9Zkr7bnNCLK48uxmmK0TySm22eiuw==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.9.tgz", + "integrity": "sha512-z4mQK9dAN6byRA/vsSgQiPeuO63wdiDxZ9yg9iyX2QTzKuQM7T4xlBoeUP/J8uiFkqxkcWndWi+W7bXdPbt27Q==", "cpu": [ "arm64" ], @@ -1451,9 +1502,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.27.3.tgz", - "integrity": "sha512-ecE36ZBMLINqiTtSNQ1vzWc5pXLQHlf/oqGp/bSbi7iedcjcNb6QbCBNG73Euyy2C+l/fn8qKWEwxr+0SSfs3w==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.9.tgz", + "integrity": "sha512-KB48mPtaoHy1AwDNkAJfHXvHp24H0ryZog28spEs0V48l3H1fr4i37tiyHsgKZJnCmvxsbATdZGBpbmxTE3a9w==", "cpu": [ "ia32" ], @@ -1465,9 +1516,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.27.3.tgz", - "integrity": "sha512-vliZLrDmYKyaUoMzEbMTg2JkerfBjn03KmAw9CykO0Zzkzoyd7o3iZNam/TpyWNjNT+Cz2iO3P9Smv2wgrR+Eg==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.9.tgz", + "integrity": "sha512-AyleYRPU7+rgkMWbEh71fQlrzRfeP6SyMnRf9XX4fCdDPAJumdSBqYEcWPMzVQ4ScAl7E4oFfK0GUVn77xSwbw==", "cpu": [ "x64" ], @@ -1666,9 +1717,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "22.10.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.9.tgz", - "integrity": "sha512-Ir6hwgsKyNESl/gLOcEz3krR4CBGgliDqBQ2ma4wIhEx0w+xnoeTq3tdrNw15kU3SxogDjOgv9sqdtLW8mIHaw==", + "version": "22.13.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", + "integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==", "dev": true, "license": "MIT", "dependencies": { @@ -1682,20 +1733,21 @@ "dev": true }, "node_modules/@types/oidc-provider": { - "version": "8.5.2", - "resolved": "https://registry.npmjs.org/@types/oidc-provider/-/oidc-provider-8.5.2.tgz", - "integrity": "sha512-NiD3VG49+cRCAAe8+uZLM4onOcX8y9+cwaml8JG1qlgc98rWoCRgsnOB4Ypx+ysays5jiwzfUgT0nWyXPB/9uQ==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@types/oidc-provider/-/oidc-provider-8.8.0.tgz", + "integrity": "sha512-9Jtutw4dyAz0PN8EWlxqeNrGHsEZ9EH4QZjfkZIbhmKuiuHIrzoz/S1zHNXX8ogfhWtPp1swMyzXBSo6RTTj1Q==", "dev": true, "license": "MIT", "dependencies": { + "@types/keygrip": "*", "@types/koa": "*", "@types/node": "*" } }, "node_modules/@types/pg": { - "version": "8.11.10", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.10.tgz", - "integrity": "sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==", + "version": "8.11.11", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.11.tgz", + "integrity": "sha512-kGT1qKM8wJQ5qlawUrEkXgvMSXoV213KfMGXcwfDwUIfUHXqXYXOfS1nE1LINRJVVVx5wCm70XnFlMHaIcQAfw==", "dev": true, "license": "MIT", "dependencies": { @@ -1830,21 +1882,21 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.20.0.tgz", - "integrity": "sha512-naduuphVw5StFfqp4Gq4WhIBE2gN1GEmMUExpJYknZJdRnc+2gDzB8Z3+5+/Kv33hPQRDGzQO/0opHE72lZZ6A==", + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.25.0.tgz", + "integrity": "sha512-VM7bpzAe7JO/BFf40pIT1lJqS/z1F8OaSsUB3rpFJucQA4cOSuH2RVVVkFULN+En0Djgr29/jb4EQnedUo95KA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/type-utils": "8.20.0", - "@typescript-eslint/utils": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", + "@typescript-eslint/scope-manager": "8.25.0", + "@typescript-eslint/type-utils": "8.25.0", + "@typescript-eslint/utils": "8.25.0", + "@typescript-eslint/visitor-keys": "8.25.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1860,16 +1912,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.20.0.tgz", - "integrity": "sha512-gKXG7A5HMyjDIedBi6bUrDcun8GIjnI8qOwVLiY3rx6T/sHP/19XLJOnIq/FgQvWLHja5JN/LSE7eklNBr612g==", + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.25.0.tgz", + "integrity": "sha512-4gbs64bnbSzu4FpgMiQ1A+D+urxkoJk/kqlDJ2W//5SygaEiAP2B4GoS7TEdxgwol2el03gckFV9lJ4QOMiiHg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/typescript-estree": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", + "@typescript-eslint/scope-manager": "8.25.0", + "@typescript-eslint/types": "8.25.0", + "@typescript-eslint/typescript-estree": "8.25.0", + "@typescript-eslint/visitor-keys": "8.25.0", "debug": "^4.3.4" }, "engines": { @@ -1885,14 +1937,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.20.0.tgz", - "integrity": "sha512-J7+VkpeGzhOt3FeG1+SzhiMj9NzGD/M6KoGn9f4dbz3YzK9hvbhVTmLj/HiTp9DazIzJ8B4XcM80LrR9Dm1rJw==", + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.25.0.tgz", + "integrity": "sha512-6PPeiKIGbgStEyt4NNXa2ru5pMzQ8OYKO1hX1z53HMomrmiSB+R5FmChgQAP1ro8jMtNawz+TRQo/cSXrauTpg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0" + "@typescript-eslint/types": "8.25.0", + "@typescript-eslint/visitor-keys": "8.25.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1903,16 +1955,16 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.20.0.tgz", - "integrity": "sha512-bPC+j71GGvA7rVNAHAtOjbVXbLN5PkwqMvy1cwGeaxUoRQXVuKCebRoLzm+IPW/NtFFpstn1ummSIasD5t60GA==", + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.25.0.tgz", + "integrity": "sha512-d77dHgHWnxmXOPJuDWO4FDWADmGQkN5+tt6SFRZz/RtCWl4pHgFl3+WdYCn16+3teG09DY6XtEpf3gGD0a186g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.20.0", - "@typescript-eslint/utils": "8.20.0", + "@typescript-eslint/typescript-estree": "8.25.0", + "@typescript-eslint/utils": "8.25.0", "debug": "^4.3.4", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1927,9 +1979,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.20.0.tgz", - "integrity": "sha512-cqaMiY72CkP+2xZRrFt3ExRBu0WmVitN/rYPZErA80mHjHx/Svgp8yfbzkJmDoQ/whcytOPO9/IZXnOc+wigRA==", + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.25.0.tgz", + "integrity": "sha512-+vUe0Zb4tkNgznQwicsvLUJgZIRs6ITeWSCclX1q85pR1iOiaj+4uZJIUp//Z27QWu5Cseiw3O3AR8hVpax7Aw==", "dev": true, "license": "MIT", "engines": { @@ -1941,20 +1993,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.20.0.tgz", - "integrity": "sha512-Y7ncuy78bJqHI35NwzWol8E0X7XkRVS4K4P4TCyzWkOJih5NDvtoRDW4Ba9YJJoB2igm9yXDdYI/+fkiiAxPzA==", + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.25.0.tgz", + "integrity": "sha512-ZPaiAKEZ6Blt/TPAx5Ot0EIB/yGtLI2EsGoY6F7XKklfMxYQyvtL+gT/UCqkMzO0BVFHLDlzvFqQzurYahxv9Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", + "@typescript-eslint/types": "8.25.0", + "@typescript-eslint/visitor-keys": "8.25.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1994,16 +2046,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.20.0.tgz", - "integrity": "sha512-dq70RUw6UK9ei7vxc4KQtBRk7qkHZv447OUZ6RPQMQl71I3NZxQJX/f32Smr+iqWrB02pHKn2yAdHBb0KNrRMA==", + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.25.0.tgz", + "integrity": "sha512-syqRbrEv0J1wywiLsK60XzHnQe/kRViI3zwFALrNEgnntn1l24Ra2KvOAWwWbWZ1lBZxZljPDGOq967dsl6fkA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/typescript-estree": "8.20.0" + "@typescript-eslint/scope-manager": "8.25.0", + "@typescript-eslint/types": "8.25.0", + "@typescript-eslint/typescript-estree": "8.25.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2018,13 +2070,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.20.0.tgz", - "integrity": "sha512-v/BpkeeYAsPkKCkR8BDwcno0llhzWVqPOamQrAEMdpZav2Y9OVjd9dwJyBLJWwf335B5DmlifECIkZRJCaGaHA==", + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.25.0.tgz", + "integrity": "sha512-kCYXKAum9CecGVHGij7muybDfTS2sD3t0L4bJsEZLkyrXUImiCTq1M3LG2SRtOhiHFwMR9wAFplpT6XHYjTkwQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.20.0", + "@typescript-eslint/types": "8.25.0", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -2049,9 +2101,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.3.tgz", - "integrity": "sha512-uVbJ/xhImdNtzPnLyxCZJMTeTIYdgcC2nWtBBBpR1H6z0w8m7D+9/zrDIx2nNxgMg9r+X8+RY2qVpUDeW2b3nw==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.7.tgz", + "integrity": "sha512-Av8WgBJLTrfLOer0uy3CxjlVuWK4CzcLBndW1Nm2vI+3hZ2ozHututkfc7Blu1u6waeQ7J8gzPK/AsBRnWA5mQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2072,8 +2124,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.0.3", - "vitest": "3.0.3" + "@vitest/browser": "3.0.7", + "vitest": "3.0.7" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2100,15 +2152,15 @@ } }, "node_modules/@vitest/expect": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.3.tgz", - "integrity": "sha512-SbRCHU4qr91xguu+dH3RUdI5dC86zm8aZWydbp961aIR7G8OYNN6ZiayFuf9WAngRbFOfdrLHCGgXTj3GtoMRQ==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.7.tgz", + "integrity": "sha512-QP25f+YJhzPfHrHfYHtvRn+uvkCFCqFtW9CktfBxmB+25QqWsx7VB2As6f4GmwllHLDhXNHvqedwhvMmSnNmjw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.0.3", - "@vitest/utils": "3.0.3", - "chai": "^5.1.2", + "@vitest/spy": "3.0.7", + "@vitest/utils": "3.0.7", + "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, "funding": { @@ -2116,13 +2168,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.3.tgz", - "integrity": "sha512-XT2XBc4AN9UdaxJAeIlcSZ0ILi/GzmG5G8XSly4gaiqIvPV3HMTSIDZWJVX6QRJ0PX1m+W8Cy0K9ByXNb/bPIA==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.7.tgz", + "integrity": "sha512-qui+3BLz9Eonx4EAuR/i+QlCX6AUZ35taDQgwGkK/Tw6/WgwodSrjN1X2xf69IA/643ZX5zNKIn2svvtZDrs4w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.0.3", + "@vitest/spy": "3.0.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -2143,9 +2195,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.3.tgz", - "integrity": "sha512-gCrM9F7STYdsDoNjGgYXKPq4SkSxwwIU5nkaQvdUxiQ0EcNlez+PdKOVIsUJvh9P9IeIFmjn4IIREWblOBpP2Q==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.7.tgz", + "integrity": "sha512-CiRY0BViD/V8uwuEzz9Yapyao+M9M008/9oMOSQydwbwb+CMokEq3XVaF3XK/VWaOK0Jm9z7ENhybg70Gtxsmg==", "dev": true, "license": "MIT", "dependencies": { @@ -2156,38 +2208,38 @@ } }, "node_modules/@vitest/runner": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.3.tgz", - "integrity": "sha512-Rgi2kOAk5ZxWZlwPguRJFOBmWs6uvvyAAR9k3MvjRvYrG7xYvKChZcmnnpJCS98311CBDMqsW9MzzRFsj2gX3g==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.7.tgz", + "integrity": "sha512-WeEl38Z0S2ZcuRTeyYqaZtm4e26tq6ZFqh5y8YD9YxfWuu0OFiGFUbnxNynwLjNRHPsXyee2M9tV7YxOTPZl2g==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.0.3", - "pathe": "^2.0.1" + "@vitest/utils": "3.0.7", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.3.tgz", - "integrity": "sha512-kNRcHlI4txBGztuJfPEJ68VezlPAXLRT1u5UCx219TU3kOG2DplNxhWLwDf2h6emwmTPogzLnGVwP6epDaJN6Q==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.7.tgz", + "integrity": "sha512-eqTUryJWQN0Rtf5yqCGTQWsCFOQe4eNz5Twsu21xYEcnFJtMU5XvmG0vgebhdLlrHQTSq5p8vWHJIeJQV8ovsA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.0.3", + "@vitest/pretty-format": "3.0.7", "magic-string": "^0.30.17", - "pathe": "^2.0.1" + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/spy": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.3.tgz", - "integrity": "sha512-7/dgux8ZBbF7lEIKNnEqQlyRaER9nkAL9eTmdKJkDO3hS8p59ATGwKOCUDHcBLKr7h/oi/6hP+7djQk8049T2A==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.7.tgz", + "integrity": "sha512-4T4WcsibB0B6hrKdAZTM37ekuyFZt2cGbEGd2+L0P8ov15J1/HUsUaqkXEQPNAWr4BtPPe1gI+FYfMHhEKfR8w==", "dev": true, "license": "MIT", "dependencies": { @@ -2198,14 +2250,14 @@ } }, "node_modules/@vitest/utils": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.3.tgz", - "integrity": "sha512-f+s8CvyzPtMFY1eZKkIHGhPsQgYo5qCm6O8KZoim9qm1/jT64qBgGpO5tHscNH6BzRHM+edLNOP+3vO8+8pE/A==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.7.tgz", + "integrity": "sha512-xePVpCRfooFX3rANQjwoditoXgWb1MaFbzmGuPP59MK6i13mrnDw/yEIyJudLeW6/38mCNcwCiJIGmpDPibAIg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.0.3", - "loupe": "^3.1.2", + "@vitest/pretty-format": "3.0.7", + "loupe": "^3.1.3", "tinyrainbow": "^2.0.0" }, "funding": { @@ -2554,9 +2606,9 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz", - "integrity": "sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", + "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", "dev": true, "license": "MIT", "dependencies": { @@ -3036,9 +3088,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", + "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3046,32 +3098,34 @@ "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" + "@esbuild/aix-ppc64": "0.25.0", + "@esbuild/android-arm": "0.25.0", + "@esbuild/android-arm64": "0.25.0", + "@esbuild/android-x64": "0.25.0", + "@esbuild/darwin-arm64": "0.25.0", + "@esbuild/darwin-x64": "0.25.0", + "@esbuild/freebsd-arm64": "0.25.0", + "@esbuild/freebsd-x64": "0.25.0", + "@esbuild/linux-arm": "0.25.0", + "@esbuild/linux-arm64": "0.25.0", + "@esbuild/linux-ia32": "0.25.0", + "@esbuild/linux-loong64": "0.25.0", + "@esbuild/linux-mips64el": "0.25.0", + "@esbuild/linux-ppc64": "0.25.0", + "@esbuild/linux-riscv64": "0.25.0", + "@esbuild/linux-s390x": "0.25.0", + "@esbuild/linux-x64": "0.25.0", + "@esbuild/netbsd-arm64": "0.25.0", + "@esbuild/netbsd-x64": "0.25.0", + "@esbuild/openbsd-arm64": "0.25.0", + "@esbuild/openbsd-x64": "0.25.0", + "@esbuild/sunos-x64": "0.25.0", + "@esbuild/win32-arm64": "0.25.0", + "@esbuild/win32-ia32": "0.25.0", + "@esbuild/win32-x64": "0.25.0" } }, "node_modules/escalade": { @@ -3103,22 +3157,22 @@ } }, "node_modules/eslint": { - "version": "9.18.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.18.0.tgz", - "integrity": "sha512-+waTfRWQlSbpt3KWE+CjrPPYnbq9kfZIYUqapc0uBXyjTp8aYXZDsUH16m39Ryq3NjAVP4tjuF7KaukeqoCoaA==", + "version": "9.21.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.21.0.tgz", + "integrity": "sha512-KjeihdFqTPhOMXTt7StsDxriV4n66ueuF/jfPNC3j/lduHwr/ijDwJMsF+wyMJethgiKi5wniIE243vi07d3pg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.19.0", - "@eslint/core": "^0.10.0", - "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "9.18.0", - "@eslint/plugin-kit": "^0.2.5", + "@eslint/config-array": "^0.19.2", + "@eslint/core": "^0.12.0", + "@eslint/eslintrc": "^3.3.0", + "@eslint/js": "9.21.0", + "@eslint/plugin-kit": "^0.2.7", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.1", + "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", @@ -3163,9 +3217,9 @@ } }, "node_modules/eslint-config-prettier": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.0.1.tgz", - "integrity": "sha512-lZBts941cyJyeaooiKxAtzoPHTN+GbQTJFAIdQbRhA4/8whaAraEh47Whw/ZFfrjNSnlAxqfm9i0XVAEkULjCw==", + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.0.2.tgz", + "integrity": "sha512-1105/17ZIMjmCOJOPNfVdbXafLCLj3hPmkmB7dLgt7XsQ/zkxSuDerE/xgO3RxoHysR1N1whmquY0lSn2O0VLg==", "dev": true, "license": "MIT", "bin": { @@ -3176,9 +3230,9 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.2.tgz", - "integrity": "sha512-1yI3/hf35wmlq66C8yOyrujQnel+v5l1Vop5Cl2I6ylyNTT1JbuUUnV3/41PzwTzcyDp/oF0jWE3HXvcH5AQOQ==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.3.tgz", + "integrity": "sha512-qJ+y0FfCp/mQYQ/vWQ3s7eUlFEL4PyKfAJxsnYTJ4YT73nsJBWqmEpFryxV9OeUiqmsTsYJ5Y+KDNaeP31wrRw==", "dev": true, "license": "MIT", "dependencies": { @@ -3240,6 +3294,19 @@ "eslint": ">=8.56.0" } }, + "node_modules/eslint-plugin-unicorn/node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint-scope": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz", @@ -3491,9 +3558,9 @@ "dev": true }, "node_modules/fastq": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", - "integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==", + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", "dev": true, "license": "ISC", "dependencies": { @@ -3756,9 +3823,9 @@ } }, "node_modules/globals": { - "version": "15.14.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.14.0.tgz", - "integrity": "sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==", + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.0.0.tgz", + "integrity": "sha512-iInW14XItCXET01CQFqudPOWP2jYMl7T+QRQT+UNcR/iQncN/F0UNpgd76iFkBPgNQb4+X3LV9tLJYzwh+Gl3A==", "dev": true, "license": "MIT", "engines": { @@ -4270,9 +4337,9 @@ } }, "node_modules/jose": { - "version": "5.9.6", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", - "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==", + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", "dev": true, "license": "MIT", "funding": { @@ -4292,10 +4359,11 @@ } }, "node_modules/jsesc": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", - "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, + "license": "MIT", "bin": { "jsesc": "bin/jsesc" }, @@ -4349,10 +4417,11 @@ } }, "node_modules/koa": { - "version": "2.15.3", - "resolved": "https://registry.npmjs.org/koa/-/koa-2.15.3.tgz", - "integrity": "sha512-j/8tY9j5t+GVMLeioLaxweJiKUayFhlGqNTzf2ZGwL0ZCQijd2RLHK0SLW5Tsko8YyyqCZC2cojIb0/s62qTAg==", + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.15.4.tgz", + "integrity": "sha512-7fNBIdrU2PEgLljXoPWoyY4r1e+ToWCmzS/wwMPbUNs7X+5MMET1ObhJBlUkF5uZG9B6QhM2zS1TsH6adegkiQ==", "dev": true, + "license": "MIT", "dependencies": { "accepts": "^1.3.5", "cache-content-type": "^1.0.0", @@ -4476,9 +4545,9 @@ "dev": true }, "node_modules/loupe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", - "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", + "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", "dev": true, "license": "MIT" }, @@ -4709,9 +4778,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.8.tgz", - "integrity": "sha512-TcJPw+9RV9dibz1hHUzlLVy8N4X9TnwirAjrU08Juo6BNKggzVfP2ZJ/3ZUSq15Xl5i85i+Z89XBO90pB2PghQ==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.9.tgz", + "integrity": "sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==", "dev": true, "funding": [ { @@ -4869,21 +4938,21 @@ "dev": true }, "node_modules/oidc-provider": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-8.6.0.tgz", - "integrity": "sha512-LTzQza+KA72fFWe/70ttjTpCPvwZRoaydPFY2izNfQjo6u33lFOzJeqA9Q0TblTShkaH56ChoE2KdMYIQlNHdw==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-8.8.0.tgz", + "integrity": "sha512-5b4QncVOVsU8BLpD0ofQBRq2aX9Juhc0wFbaZSQbAmgN1jVfCZfYt3GEPPmJ8Tc/mvfX735PNH/LnuyWzMn9tQ==", "dev": true, "license": "MIT", "dependencies": { "@koa/cors": "^5.0.0", "@koa/router": "^13.1.0", - "debug": "^4.3.7", + "debug": "^4.4.0", "eta": "^3.5.0", "got": "^13.0.0", "jose": "^5.9.6", - "jsesc": "^3.0.2", - "koa": "^2.15.3", - "nanoid": "^5.0.8", + "jsesc": "^3.1.0", + "koa": "^2.15.4", + "nanoid": "^5.0.9", "object-hash": "^3.0.0", "oidc-token-hash": "^5.0.3", "quick-lru": "^7.0.0", @@ -4893,6 +4962,24 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/oidc-provider/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/oidc-token-hash": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", @@ -5107,9 +5194,9 @@ "dev": true }, "node_modules/pathe": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.2.tgz", - "integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, @@ -5124,15 +5211,15 @@ } }, "node_modules/pg": { - "version": "8.13.1", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.1.tgz", - "integrity": "sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==", + "version": "8.13.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.3.tgz", + "integrity": "sha512-P6tPt9jXbL9HVu/SSRERNYaYG++MjnscnegFh9pPHihfoBSujsrka0hyuymMzeJKFWrcG8wvCKy8rCe8e5nDUQ==", "dev": true, "license": "MIT", "dependencies": { "pg-connection-string": "^2.7.0", - "pg-pool": "^3.7.0", - "pg-protocol": "^1.7.0", + "pg-pool": "^3.7.1", + "pg-protocol": "^1.7.1", "pg-types": "^2.1.0", "pgpass": "1.x" }, @@ -5184,9 +5271,9 @@ } }, "node_modules/pg-pool": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz", - "integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==", + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.1.tgz", + "integrity": "sha512-xIOsFoh7Vdhojas6q3596mXFsR8nwBQBXX5JiV7p9buEVAGqYL4yFzclON5P9vFrpu1u7Zwl2oriyDa89n0wbw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -5194,9 +5281,9 @@ } }, "node_modules/pg-protocol": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.0.tgz", - "integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.1.tgz", + "integrity": "sha512-gjTHWGYWsEgy9MsY0Gp6ZJxV24IjDqdpTW7Eh0x+WfJLFsm/TJx1MzL6T0D88mBvkpxotCQ6TwW6N+Kko7lhgQ==", "dev": true, "license": "MIT" }, @@ -5246,13 +5333,13 @@ } }, "node_modules/playwright": { - "version": "1.49.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz", - "integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==", + "version": "1.50.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.50.1.tgz", + "integrity": "sha512-G8rwsOQJ63XG6BbKj2w5rHeavFjy5zynBA9zsJMMtBoe/Uf757oG12NXz6e6OirF7RCrTVAKFXbLmn1RbL7Qaw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.49.1" + "playwright-core": "1.50.1" }, "bin": { "playwright": "cli.js" @@ -5265,9 +5352,9 @@ } }, "node_modules/playwright-core": { - "version": "1.49.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz", - "integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==", + "version": "1.50.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.50.1.tgz", + "integrity": "sha512-ra9fsNWayuYumt+NiM069M6OkcRb1FZSK8bgi66AtpFoWkg2+y0bJSNmkFrWhMbEBbVKC/EruAHH3g0zmtwGmQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5296,9 +5383,9 @@ } }, "node_modules/postcss": { - "version": "8.4.49", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", - "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", "dev": true, "funding": [ { @@ -5316,7 +5403,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", + "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5325,9 +5412,9 @@ } }, "node_modules/postcss/node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "dev": true, "funding": [ { @@ -5398,9 +5485,9 @@ } }, "node_modules/prettier": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", - "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.2.tgz", + "integrity": "sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg==", "dev": true, "license": "MIT", "bin": { @@ -5709,9 +5796,9 @@ } }, "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "dev": true, "license": "MIT", "engines": { @@ -5735,9 +5822,9 @@ } }, "node_modules/rollup": { - "version": "4.27.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.27.3.tgz", - "integrity": "sha512-SLsCOnlmGt9VoZ9Ek8yBK8tAdmPHeppkw+Xa7yDlCEhDTvwYei03JlWo1fdc7YTfLZ4tD8riJCUyAgTbszk1fQ==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.9.tgz", + "integrity": "sha512-nF5XYqWWp9hx/LrpC8sZvvvmq0TeTjQgaZHYmAgwysT9nh8sWnZhBnM8ZyVbbJFIQBLwHDNoMqsBZBbUo4U8sQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5751,24 +5838,25 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.27.3", - "@rollup/rollup-android-arm64": "4.27.3", - "@rollup/rollup-darwin-arm64": "4.27.3", - "@rollup/rollup-darwin-x64": "4.27.3", - "@rollup/rollup-freebsd-arm64": "4.27.3", - "@rollup/rollup-freebsd-x64": "4.27.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.27.3", - "@rollup/rollup-linux-arm-musleabihf": "4.27.3", - "@rollup/rollup-linux-arm64-gnu": "4.27.3", - "@rollup/rollup-linux-arm64-musl": "4.27.3", - "@rollup/rollup-linux-powerpc64le-gnu": "4.27.3", - "@rollup/rollup-linux-riscv64-gnu": "4.27.3", - "@rollup/rollup-linux-s390x-gnu": "4.27.3", - "@rollup/rollup-linux-x64-gnu": "4.27.3", - "@rollup/rollup-linux-x64-musl": "4.27.3", - "@rollup/rollup-win32-arm64-msvc": "4.27.3", - "@rollup/rollup-win32-ia32-msvc": "4.27.3", - "@rollup/rollup-win32-x64-msvc": "4.27.3", + "@rollup/rollup-android-arm-eabi": "4.34.9", + "@rollup/rollup-android-arm64": "4.34.9", + "@rollup/rollup-darwin-arm64": "4.34.9", + "@rollup/rollup-darwin-x64": "4.34.9", + "@rollup/rollup-freebsd-arm64": "4.34.9", + "@rollup/rollup-freebsd-x64": "4.34.9", + "@rollup/rollup-linux-arm-gnueabihf": "4.34.9", + "@rollup/rollup-linux-arm-musleabihf": "4.34.9", + "@rollup/rollup-linux-arm64-gnu": "4.34.9", + "@rollup/rollup-linux-arm64-musl": "4.34.9", + "@rollup/rollup-linux-loongarch64-gnu": "4.34.9", + "@rollup/rollup-linux-powerpc64le-gnu": "4.34.9", + "@rollup/rollup-linux-riscv64-gnu": "4.34.9", + "@rollup/rollup-linux-s390x-gnu": "4.34.9", + "@rollup/rollup-linux-x64-gnu": "4.34.9", + "@rollup/rollup-linux-x64-musl": "4.34.9", + "@rollup/rollup-win32-arm64-msvc": "4.34.9", + "@rollup/rollup-win32-ia32-msvc": "4.34.9", + "@rollup/rollup-win32-x64-msvc": "4.34.9", "fsevents": "~2.3.2" } }, @@ -6343,9 +6431,9 @@ "dev": true }, "node_modules/ts-api-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.0.tgz", - "integrity": "sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz", + "integrity": "sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==", "dev": true, "license": "MIT", "engines": { @@ -6505,21 +6593,21 @@ } }, "node_modules/vite": { - "version": "5.4.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", - "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz", + "integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "esbuild": "^0.25.0", + "postcss": "^8.5.3", + "rollup": "^4.30.1" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -6528,19 +6616,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", - "terser": "^5.4.0" + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -6561,20 +6655,26 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, "node_modules/vite-node": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.3.tgz", - "integrity": "sha512-0sQcwhwAEw/UJGojbhOrnq3HtiZ3tC7BzpAa0lx3QaTX0S3YX70iGcik25UBdB96pmdwjyY2uyKNYruxCDmiEg==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.7.tgz", + "integrity": "sha512-2fX0QwX4GkkkpULXdT1Pf4q0tC1i1lFOyseKoonavXUNlQ77KpW2XqBGGNIm/J4Ows4KxgGJzDguYVPKwG/n5A==", "dev": true, "license": "MIT", "dependencies": { "cac": "^6.7.14", "debug": "^4.4.0", "es-module-lexer": "^1.6.0", - "pathe": "^2.0.1", + "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0" }, "bin": { @@ -6621,31 +6721,31 @@ } }, "node_modules/vitest": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.3.tgz", - "integrity": "sha512-dWdwTFUW9rcnL0LyF2F+IfvNQWB0w9DERySCk8VMG75F8k25C7LsZoh6XfCjPvcR8Nb+Lqi9JKr6vnzH7HSrpQ==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.7.tgz", + "integrity": "sha512-IP7gPK3LS3Fvn44x30X1dM9vtawm0aesAa2yBIZ9vQf+qB69NXC5776+Qmcr7ohUXIQuLhk7xQR0aSUIDPqavg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "3.0.3", - "@vitest/mocker": "3.0.3", - "@vitest/pretty-format": "^3.0.3", - "@vitest/runner": "3.0.3", - "@vitest/snapshot": "3.0.3", - "@vitest/spy": "3.0.3", - "@vitest/utils": "3.0.3", - "chai": "^5.1.2", + "@vitest/expect": "3.0.7", + "@vitest/mocker": "3.0.7", + "@vitest/pretty-format": "^3.0.7", + "@vitest/runner": "3.0.7", + "@vitest/snapshot": "3.0.7", + "@vitest/spy": "3.0.7", + "@vitest/utils": "3.0.7", + "chai": "^5.2.0", "debug": "^4.4.0", "expect-type": "^1.1.0", "magic-string": "^0.30.17", - "pathe": "^2.0.1", + "pathe": "^2.0.3", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.0.3", + "vite-node": "3.0.7", "why-is-node-running": "^2.3.0" }, "bin": { @@ -6659,9 +6759,10 @@ }, "peerDependencies": { "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.0.3", - "@vitest/ui": "3.0.3", + "@vitest/browser": "3.0.7", + "@vitest/ui": "3.0.7", "happy-dom": "*", "jsdom": "*" }, @@ -6669,6 +6770,9 @@ "@edge-runtime/vm": { "optional": true }, + "@types/debug": { + "optional": true + }, "@types/node": { "optional": true }, diff --git a/e2e/package.json b/e2e/package.json index 73b4a2dc29..e7dacf3c26 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.125.1", + "version": "1.129.0", "description": "", "main": "index.js", "type": "module", @@ -25,7 +25,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^22.10.9", + "@types/node": "^22.13.9", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", @@ -38,7 +38,7 @@ "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^56.0.1", "exiftool-vendored": "^28.3.1", - "globals": "^15.9.0", + "globals": "^16.0.0", "jose": "^5.6.3", "luxon": "^3.4.4", "oidc-provider": "^8.5.1", @@ -53,6 +53,6 @@ "vitest": "^3.0.0" }, "volta": { - "node": "22.13.1" + "node": "22.14.0" } } diff --git a/e2e/src/api/specs/album.e2e-spec.ts b/e2e/src/api/specs/album.e2e-spec.ts index 5b40234e8d..cede49f469 100644 --- a/e2e/src/api/specs/album.e2e-spec.ts +++ b/e2e/src/api/specs/album.e2e-spec.ts @@ -22,79 +22,92 @@ const user1NotShared = 'user1NotShared'; const user2SharedUser = 'user2SharedUser'; const user2SharedLink = 'user2SharedLink'; const user2NotShared = 'user2NotShared'; +const user4DeletedAsset = 'user4DeletedAsset'; +const user4Empty = 'user4Empty'; describe('/albums', () => { let admin: LoginResponseDto; let user1: LoginResponseDto; let user1Asset1: AssetMediaResponseDto; let user1Asset2: AssetMediaResponseDto; + let user4Asset1: AssetMediaResponseDto; let user1Albums: AlbumResponseDto[]; let user2: LoginResponseDto; let user2Albums: AlbumResponseDto[]; + let deletedAssetAlbum: AlbumResponseDto; let user3: LoginResponseDto; // deleted + let user4: LoginResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup(); - [user1, user2, user3] = await Promise.all([ + [user1, user2, user3, user4] = await Promise.all([ utils.userSetup(admin.accessToken, createUserDto.user1), utils.userSetup(admin.accessToken, createUserDto.user2), utils.userSetup(admin.accessToken, createUserDto.user3), + utils.userSetup(admin.accessToken, createUserDto.user4), ]); - [user1Asset1, user1Asset2] = await Promise.all([ + [user1Asset1, user1Asset2, user4Asset1] = await Promise.all([ utils.createAsset(user1.accessToken, { isFavorite: true }), utils.createAsset(user1.accessToken), + utils.createAsset(user1.accessToken), ]); - user1Albums = await Promise.all([ - utils.createAlbum(user1.accessToken, { - albumName: user1SharedEditorUser, - albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Editor }], - assetIds: [user1Asset1.id], - }), - utils.createAlbum(user1.accessToken, { - albumName: user1SharedLink, - assetIds: [user1Asset1.id], - }), - utils.createAlbum(user1.accessToken, { - albumName: user1NotShared, - assetIds: [user1Asset1.id, user1Asset2.id], - }), - utils.createAlbum(user1.accessToken, { - albumName: user1SharedViewerUser, - albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }], - assetIds: [user1Asset1.id], + [user1Albums, user2Albums, deletedAssetAlbum] = await Promise.all([ + Promise.all([ + utils.createAlbum(user1.accessToken, { + albumName: user1SharedEditorUser, + albumUsers: [ + { userId: admin.userId, role: AlbumUserRole.Editor }, + { userId: user2.userId, role: AlbumUserRole.Editor }, + ], + assetIds: [user1Asset1.id], + }), + utils.createAlbum(user1.accessToken, { + albumName: user1SharedLink, + assetIds: [user1Asset1.id], + }), + utils.createAlbum(user1.accessToken, { + albumName: user1NotShared, + assetIds: [user1Asset1.id, user1Asset2.id], + }), + utils.createAlbum(user1.accessToken, { + albumName: user1SharedViewerUser, + albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }], + assetIds: [user1Asset1.id], + }), + ]), + Promise.all([ + utils.createAlbum(user2.accessToken, { + albumName: user2SharedUser, + albumUsers: [ + { userId: user1.userId, role: AlbumUserRole.Editor }, + { userId: user3.userId, role: AlbumUserRole.Editor }, + ], + }), + utils.createAlbum(user2.accessToken, { albumName: user2SharedLink }), + utils.createAlbum(user2.accessToken, { albumName: user2NotShared }), + ]), + utils.createAlbum(user4.accessToken, { albumName: user4DeletedAsset }), + utils.createAlbum(user4.accessToken, { albumName: user4Empty }), + utils.createAlbum(user3.accessToken, { + albumName: 'Deleted', + albumUsers: [{ userId: user1.userId, role: AlbumUserRole.Editor }], }), ]); - user2Albums = await Promise.all([ - utils.createAlbum(user2.accessToken, { - albumName: user2SharedUser, - albumUsers: [ - { userId: user1.userId, role: AlbumUserRole.Editor }, - { userId: user3.userId, role: AlbumUserRole.Editor }, - ], - }), - utils.createAlbum(user2.accessToken, { albumName: user2SharedLink }), - utils.createAlbum(user2.accessToken, { albumName: user2NotShared }), - ]); - - await utils.createAlbum(user3.accessToken, { - albumName: 'Deleted', - albumUsers: [{ userId: user1.userId, role: AlbumUserRole.Editor }], - }); - - await addAssetsToAlbum( - { id: user2Albums[0].id, bulkIdsDto: { ids: [user1Asset1.id, user1Asset2.id] } }, - { headers: asBearerAuth(user1.accessToken) }, - ); - - user2Albums[0] = await getAlbumInfo({ id: user2Albums[0].id }, { headers: asBearerAuth(user2.accessToken) }); - await Promise.all([ + addAssetsToAlbum( + { id: user2Albums[0].id, bulkIdsDto: { ids: [user1Asset1.id, user1Asset2.id] } }, + { headers: asBearerAuth(user1.accessToken) }, + ), + addAssetsToAlbum( + { id: deletedAssetAlbum.id, bulkIdsDto: { ids: [user4Asset1.id] } }, + { headers: asBearerAuth(user4.accessToken) }, + ), // add shared link to user1SharedLink album utils.createSharedLink(user1.accessToken, { type: SharedLinkType.Album, @@ -107,7 +120,11 @@ describe('/albums', () => { }), ]); - await deleteUserAdmin({ id: user3.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) }); + [user2Albums[0]] = await Promise.all([ + getAlbumInfo({ id: user2Albums[0].id }, { headers: asBearerAuth(user2.accessToken) }), + deleteUserAdmin({ id: user3.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) }), + utils.deleteAssets(user1.accessToken, [user4Asset1.id]), + ]); }); describe('GET /albums', () => { @@ -284,6 +301,25 @@ describe('/albums', () => { expect(status).toBe(200); expect(body).toHaveLength(5); }); + + it('should return empty albums and albums where all assets are deleted', async () => { + const { status, body } = await request(app).get('/albums').set('Authorization', `Bearer ${user4.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ownerId: user4.userId, + albumName: user4DeletedAsset, + shared: false, + }), + expect.objectContaining({ + ownerId: user4.userId, + albumName: user4Empty, + shared: false, + }), + ]), + ); + }); }); describe('GET /albums/:id', () => { @@ -362,6 +398,26 @@ describe('/albums', () => { shared: true, }); }); + + it('should not count trashed assets', async () => { + await utils.deleteAssets(user1.accessToken, [user1Asset2.id]); + + const { status, body } = await request(app) + .get(`/albums/${user2Albums[0].id}?withoutAssets=true`) + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual({ + ...user2Albums[0], + assets: [], + assetCount: 1, + lastModifiedAssetTimestamp: expect.any(String), + endDate: expect.any(String), + startDate: expect.any(String), + albumUsers: expect.any(Array), + shared: true, + }); + }); }); describe('GET /albums/statistics', () => { diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 32cbdd6df8..8700356256 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -4,7 +4,6 @@ import { AssetResponseDto, AssetTypeEnum, getAssetInfo, - getConfig, getMyUser, LoginResponseDto, SharedLinkType, @@ -45,8 +44,6 @@ const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-sp const ratingAssetFilepath = `${testAssetDir}/metadata/rating/mongolels.jpg`; const facesAssetFilepath = `${testAssetDir}/metadata/faces/portrait.jpg`; -const getSystemConfig = (accessToken: string) => getConfig({ headers: asBearerAuth(accessToken) }); - const readTags = async (bytes: Buffer, filename: string) => { const filepath = join(tempDir, filename); await writeFile(filepath, bytes); @@ -228,7 +225,7 @@ describe('/asset', () => { }); it('should get the asset faces', async () => { - const config = await getSystemConfig(admin.accessToken); + const config = await utils.getSystemConfig(admin.accessToken); config.metadata.faces.import = true; await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) }); @@ -701,6 +698,20 @@ describe('/asset', () => { expect(status).toEqual(200); }); + it('should set the negative rating', async () => { + const { status, body } = await request(app) + .put(`/assets/${user1Assets[0].id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ rating: -1 }); + expect(body).toMatchObject({ + id: user1Assets[0].id, + exifInfo: expect.objectContaining({ + rating: -1, + }), + }); + expect(status).toEqual(200); + }); + it('should reject invalid rating', async () => { for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: null }]) { const { status, body } = await request(app) diff --git a/e2e/src/api/specs/jobs.e2e-spec.ts b/e2e/src/api/specs/jobs.e2e-spec.ts new file mode 100644 index 0000000000..a9afd8475f --- /dev/null +++ b/e2e/src/api/specs/jobs.e2e-spec.ts @@ -0,0 +1,225 @@ +import { JobCommand, JobName, LoginResponseDto, updateConfig } from '@immich/sdk'; +import { cpSync, rmSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { basename } from 'node:path'; +import { errorDto } from 'src/responses'; +import { app, asBearerAuth, testAssetDir, utils } from 'src/utils'; +import request from 'supertest'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; + +describe('/jobs', () => { + let admin: LoginResponseDto; + + beforeAll(async () => { + await utils.resetDatabase(); + admin = await utils.adminSetup({ onboarding: false }); + }); + + describe('PUT /jobs', () => { + afterEach(async () => { + await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, { + command: JobCommand.Resume, + force: false, + }); + + await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, { + command: JobCommand.Resume, + force: false, + }); + + await utils.jobCommand(admin.accessToken, JobName.FaceDetection, { + command: JobCommand.Resume, + force: false, + }); + + await utils.jobCommand(admin.accessToken, JobName.SmartSearch, { + command: JobCommand.Resume, + force: false, + }); + + await utils.jobCommand(admin.accessToken, JobName.DuplicateDetection, { + command: JobCommand.Resume, + force: false, + }); + + const config = await utils.getSystemConfig(admin.accessToken); + config.machineLearning.duplicateDetection.enabled = false; + config.machineLearning.enabled = false; + config.metadata.faces.import = false; + config.machineLearning.clip.enabled = false; + await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) }); + }); + + it('should require authentication', async () => { + const { status, body } = await request(app).put('/jobs/metadataExtraction'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should queue metadata extraction for missing assets', async () => { + const path = `${testAssetDir}/formats/raw/Nikon/D700/philadelphia.nef`; + + await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, { + command: JobCommand.Pause, + force: false, + }); + + const { id } = await utils.createAsset(admin.accessToken, { + assetData: { bytes: await readFile(path), filename: basename(path) }, + }); + + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + + { + const asset = await utils.getAssetInfo(admin.accessToken, id); + + expect(asset.exifInfo).toBeDefined(); + expect(asset.exifInfo?.make).toBeNull(); + } + + await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, { + command: JobCommand.Empty, + force: false, + }); + + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + + await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, { + command: JobCommand.Resume, + force: false, + }); + + await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, { + command: JobCommand.Start, + force: false, + }); + + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + + { + const asset = await utils.getAssetInfo(admin.accessToken, id); + + expect(asset.exifInfo).toBeDefined(); + expect(asset.exifInfo?.make).toBe('NIKON CORPORATION'); + } + }); + + it('should not re-extract metadata for existing assets', async () => { + const path = `${testAssetDir}/temp/metadata/asset.jpg`; + + cpSync(`${testAssetDir}/formats/raw/Nikon/D700/philadelphia.nef`, path); + + const { id } = await utils.createAsset(admin.accessToken, { + assetData: { bytes: await readFile(path), filename: basename(path) }, + }); + + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + + { + const asset = await utils.getAssetInfo(admin.accessToken, id); + + expect(asset.exifInfo).toBeDefined(); + expect(asset.exifInfo?.model).toBe('NIKON D700'); + } + + cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, path); + + await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, { + command: JobCommand.Start, + force: false, + }); + + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + + { + const asset = await utils.getAssetInfo(admin.accessToken, id); + + expect(asset.exifInfo).toBeDefined(); + expect(asset.exifInfo?.model).toBe('NIKON D700'); + } + + rmSync(path); + }); + + it('should queue thumbnail extraction for assets missing thumbs', async () => { + const path = `${testAssetDir}/albums/nature/tanners_ridge.jpg`; + + await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, { + command: JobCommand.Pause, + force: false, + }); + + const { id } = await utils.createAsset(admin.accessToken, { + assetData: { bytes: await readFile(path), filename: basename(path) }, + }); + + await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction); + await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration); + + const assetBefore = await utils.getAssetInfo(admin.accessToken, id); + expect(assetBefore.thumbhash).toBeNull(); + + await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, { + command: JobCommand.Empty, + force: false, + }); + + await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction); + await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration); + + await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, { + command: JobCommand.Resume, + force: false, + }); + + await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, { + command: JobCommand.Start, + force: false, + }); + + await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction); + await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration); + + const assetAfter = await utils.getAssetInfo(admin.accessToken, id); + expect(assetAfter.thumbhash).not.toBeNull(); + }); + + it('should not reload existing thumbnail when running thumb job for missing assets', async () => { + const path = `${testAssetDir}/temp/thumbs/asset1.jpg`; + + cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, path); + + const { id } = await utils.createAsset(admin.accessToken, { + assetData: { bytes: await readFile(path), filename: basename(path) }, + }); + + await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction); + await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration); + + const assetBefore = await utils.getAssetInfo(admin.accessToken, id); + + cpSync(`${testAssetDir}/albums/nature/notocactus_minimus.jpg`, path); + + await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, { + command: JobCommand.Resume, + force: false, + }); + + // This runs the missing thumbnail job + await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, { + command: JobCommand.Start, + force: false, + }); + + await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction); + await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration); + + const assetAfter = await utils.getAssetInfo(admin.accessToken, id); + + // Asset 1 thumbnail should be untouched since its thumb should not have been reloaded, even though the file was changed + expect(assetAfter.thumbhash).toEqual(assetBefore.thumbhash); + + rmSync(path); + }); + }); +}); diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts index e2e69529fb..7560672727 100644 --- a/e2e/src/api/specs/library.e2e-spec.ts +++ b/e2e/src/api/specs/library.e2e-spec.ts @@ -1,4 +1,4 @@ -import { LibraryResponseDto, LoginResponseDto, getAllLibraries, scanLibrary } from '@immich/sdk'; +import { LibraryResponseDto, LoginResponseDto, getAllLibraries } from '@immich/sdk'; import { cpSync, existsSync, rmSync, unlinkSync } from 'node:fs'; import { Socket } from 'socket.io-client'; import { userDto, uuidDto } from 'src/fixtures'; @@ -8,8 +8,6 @@ import request from 'supertest'; import { utimes } from 'utimes'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; -const scan = async (accessToken: string, id: string) => scanLibrary({ id }, { headers: asBearerAuth(accessToken) }); - describe('/libraries', () => { let admin: LoginResponseDto; let user: LoginResponseDto; @@ -298,6 +296,8 @@ describe('/libraries', () => { expect(status).toBe(204); await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); const { assets } = await utils.searchAssets(admin.accessToken, { originalPath: `${testAssetDirInternal}/temp/directoryA/assetA.png`, @@ -312,15 +312,7 @@ describe('/libraries', () => { importPaths: [`${testAssetDirInternal}/temp/directoryA`], }); - const { status } = await request(app) - .post(`/libraries/${library.id}/scan`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send(); - expect(status).toBe(204); - - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); - await utils.waitForQueueFinish(admin.accessToken, 'thumbnailGeneration'); + await utils.scan(admin.accessToken, library.id); const { assets } = await utils.searchAssets(admin.accessToken, { originalPath: `${testAssetDirInternal}/temp/directoryA/assetA.png`, @@ -340,13 +332,7 @@ describe('/libraries', () => { exclusionPatterns: ['**/directoryA'], }); - const { status } = await request(app) - .post(`/libraries/${library.id}/scan`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send(); - expect(status).toBe(204); - - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); @@ -360,13 +346,7 @@ describe('/libraries', () => { importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`], }); - const { status } = await request(app) - .post(`/libraries/${library.id}/scan`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send(); - expect(status).toBe(204); - - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); @@ -385,13 +365,7 @@ describe('/libraries', () => { utils.createImageFile(`${testAssetDir}/temp/folder, a/assetA.png`); utils.createImageFile(`${testAssetDir}/temp/folder, b/assetB.png`); - const { status } = await request(app) - .post(`/libraries/${library.id}/scan`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send(); - expect(status).toBe(204); - - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); @@ -413,13 +387,7 @@ describe('/libraries', () => { utils.createImageFile(`${testAssetDir}/temp/folder{ a/assetA.png`); utils.createImageFile(`${testAssetDir}/temp/folder} b/assetB.png`); - const { status } = await request(app) - .post(`/libraries/${library.id}/scan`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send(); - expect(status).toBe(204); - - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); @@ -471,13 +439,7 @@ describe('/libraries', () => { utils.createImageFile(`${testAssetDir}/temp/folder${char}1/asset1.png`); utils.createImageFile(`${testAssetDir}/temp/folder${char}2/asset2.png`); - const { status } = await request(app) - .post(`/libraries/${library.id}/scan`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send(); - expect(status).toBe(204); - - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); @@ -501,23 +463,12 @@ describe('/libraries', () => { utils.createImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + await utils.scan(admin.accessToken, library.id); cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/reimport/asset.jpg`); await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_001); - const { status } = await request(app) - .post(`/libraries/${library.id}/scan`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send(); - expect(status).toBe(204); - - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + await utils.scan(admin.accessToken, library.id); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, @@ -539,7 +490,7 @@ describe('/libraries', () => { utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); }); - it('should not reimport unmodified files', async () => { + it('should not reimport a file with unchanged timestamp', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, importPaths: [`${testAssetDirInternal}/temp/reimport`], @@ -548,21 +499,12 @@ describe('/libraries', () => { utils.createImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/reimport/asset.jpg`); await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000); - const { status } = await request(app) - .post(`/libraries/${library.id}/scan`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send(); - expect(status).toBe(204); - - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + await utils.scan(admin.accessToken, library.id); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, @@ -584,6 +526,47 @@ describe('/libraries', () => { utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); }); + it('should not reimport a modified file more than once', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/reimport`], + }); + + utils.createImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); + await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000); + + await utils.scan(admin.accessToken, library.id); + + cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/reimport/asset.jpg`); + await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_001); + + await utils.scan(admin.accessToken, library.id); + + cpSync(`${testAssetDir}/albums/nature/el_torcal_rocks.jpg`, `${testAssetDir}/temp/reimport/asset.jpg`); + await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_001); + + await utils.scan(admin.accessToken, library.id); + + const { assets } = await utils.searchAssets(admin.accessToken, { + libraryId: library.id, + }); + + expect(assets.count).toEqual(1); + + const asset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); + + expect(asset).toEqual( + expect.objectContaining({ + originalFileName: 'asset.jpg', + exifInfo: expect.objectContaining({ + model: 'NIKON D750', + }), + }), + ); + + utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); + }); + it('should set an asset offline if its file is missing', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, @@ -592,21 +575,14 @@ describe('/libraries', () => { utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); expect(assets.count).toBe(1); utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); - const { status } = await request(app) - .post(`/libraries/${library.id}/scan`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send(); - expect(status).toBe(204); - - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); @@ -624,8 +600,7 @@ describe('/libraries', () => { importPaths: [`${testAssetDirInternal}/temp/offline`], }); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); expect(assets.count).toBe(1); @@ -636,13 +611,7 @@ describe('/libraries', () => { importPaths: [`${testAssetDirInternal}/temp/another-path/`], }); - const { status } = await request(app) - .post(`/libraries/${library.id}/scan`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send(); - expect(status).toBe(204); - - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); @@ -662,8 +631,7 @@ describe('/libraries', () => { importPaths: [`${testAssetDirInternal}/temp`], }); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, @@ -673,8 +641,7 @@ describe('/libraries', () => { await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/directoryB/**'] }); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); expect(trashedAsset.isTrashed).toBe(true); @@ -696,19 +663,12 @@ describe('/libraries', () => { importPaths: [`${testAssetDirInternal}/temp`], }); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const { assets: assetsBefore } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); expect(assetsBefore.count).toBeGreaterThan(1); - const { status } = await request(app) - .post(`/libraries/${library.id}/scan`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send(); - expect(status).toBe(204); - - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); @@ -725,11 +685,7 @@ describe('/libraries', () => { cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - await scan(admin.accessToken, library.id); - - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + await utils.scan(admin.accessToken, library.id); const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); @@ -752,10 +708,7 @@ describe('/libraries', () => { cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + await utils.scan(admin.accessToken, library.id); const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); @@ -779,10 +732,7 @@ describe('/libraries', () => { cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + await utils.scan(admin.accessToken, library.id); const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); @@ -806,19 +756,13 @@ describe('/libraries', () => { cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + await utils.scan(admin.accessToken, library.id); cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); unlinkSync(`${testAssetDir}/temp/xmp/glarus.xmp`); await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + await utils.scan(admin.accessToken, library.id); const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); @@ -841,18 +785,12 @@ describe('/libraries', () => { cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + await utils.scan(admin.accessToken, library.id); cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + await utils.scan(admin.accessToken, library.id); const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); @@ -875,18 +813,12 @@ describe('/libraries', () => { cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + await utils.scan(admin.accessToken, library.id); cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + await utils.scan(admin.accessToken, library.id); const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); @@ -910,19 +842,13 @@ describe('/libraries', () => { cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + await utils.scan(admin.accessToken, library.id); cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); unlinkSync(`${testAssetDir}/temp/xmp/glarus.nef.xmp`); await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + await utils.scan(admin.accessToken, library.id); const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); @@ -946,18 +872,12 @@ describe('/libraries', () => { cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + await utils.scan(admin.accessToken, library.id); unlinkSync(`${testAssetDir}/temp/xmp/glarus.nef.xmp`); await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + await utils.scan(admin.accessToken, library.id); const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); @@ -981,18 +901,12 @@ describe('/libraries', () => { cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + await utils.scan(admin.accessToken, library.id); unlinkSync(`${testAssetDir}/temp/xmp/glarus.xmp`); await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + await utils.scan(admin.accessToken, library.id); const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); @@ -1015,22 +929,15 @@ describe('/libraries', () => { importPaths: [`${testAssetDirInternal}/temp/offline`], }); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); + expect(assets.count).toBe(1); + utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`); - { - const { status } = await request(app) - .post(`/libraries/${library.id}/scan`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send(); - expect(status).toBe(204); - } - - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); expect(offlineAsset.isTrashed).toBe(true); @@ -1044,15 +951,7 @@ describe('/libraries', () => { utils.renameImageFile(`${testAssetDir}/temp/offline.png`, `${testAssetDir}/temp/offline/offline.png`); - { - const { status } = await request(app) - .post(`/libraries/${library.id}/scan`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send(); - expect(status).toBe(204); - } - - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const backOnlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); @@ -1066,6 +965,58 @@ describe('/libraries', () => { } }); + it('should set a trashed offline asset to online but keep it in trash', async () => { + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/offline`], + }); + + await utils.scan(admin.accessToken, library.id); + + const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); + + expect(assets.count).toBe(1); + + await utils.deleteAssets(admin.accessToken, [assets.items[0].id]); + + { + const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); + + expect(trashedAsset.isTrashed).toBe(true); + } + + utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`); + + await utils.scan(admin.accessToken, library.id); + + const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); + expect(offlineAsset.isTrashed).toBe(true); + expect(offlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); + expect(offlineAsset.isOffline).toBe(true); + + { + const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true }); + expect(assets.count).toBe(1); + } + + utils.renameImageFile(`${testAssetDir}/temp/offline.png`, `${testAssetDir}/temp/offline/offline.png`); + + await utils.scan(admin.accessToken, library.id); + + const backOnlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); + + expect(backOnlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); + expect(backOnlineAsset.isOffline).toBe(false); + expect(backOnlineAsset.isTrashed).toBe(true); + + { + const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true }); + expect(assets.count).toBe(1); + } + }); + it('should not set an offline asset to online if its file exists, is not covered by an exclusion pattern, but is outside of all import paths', async () => { utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); @@ -1074,22 +1025,13 @@ describe('/libraries', () => { importPaths: [`${testAssetDirInternal}/temp/offline`], }); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`); - { - const { status } = await request(app) - .post(`/libraries/${library.id}/scan`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send(); - expect(status).toBe(204); - } - - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); { const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true }); @@ -1110,15 +1052,7 @@ describe('/libraries', () => { importPaths: [`${testAssetDirInternal}/temp/another-path`], }); - { - const { status } = await request(app) - .post(`/libraries/${library.id}/scan`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send(); - expect(status).toBe(204); - } - - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const stillOfflineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); @@ -1142,27 +1076,19 @@ describe('/libraries', () => { importPaths: [`${testAssetDirInternal}/temp/offline`], }); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); + { + const { assets: assetsBefore } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); + expect(assetsBefore.count).toBe(1); + } utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`); - { - const { status } = await request(app) - .post(`/libraries/${library.id}/scan`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send(); - expect(status).toBe(204); - } + await utils.scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true }); - expect(assets.count).toBe(1); - } + const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true }); + expect(assets.count).toBe(1); const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); @@ -1174,15 +1100,7 @@ describe('/libraries', () => { await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] }); - { - const { status } = await request(app) - .post(`/libraries/${library.id}/scan`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send(); - expect(status).toBe(204); - } - - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const stillOfflineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); @@ -1302,8 +1220,7 @@ describe('/libraries', () => { importPaths: [`${testAssetDirInternal}/temp`], }); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const { status, body } = await request(app) .delete(`/libraries/${library.id}`) diff --git a/e2e/src/api/specs/person.e2e-spec.ts b/e2e/src/api/specs/person.e2e-spec.ts index bb838bbae3..6e7eba74ba 100644 --- a/e2e/src/api/specs/person.e2e-spec.ts +++ b/e2e/src/api/specs/person.e2e-spec.ts @@ -1,7 +1,7 @@ -import { LoginResponseDto, PersonResponseDto } from '@immich/sdk'; +import { getPerson, LoginResponseDto, PersonResponseDto } from '@immich/sdk'; import { uuidDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; -import { app, utils } from 'src/utils'; +import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; @@ -195,12 +195,29 @@ describe('/people', () => { .send({ name: 'New Person', birthDate: '1990-01-01', + color: '#333', }); expect(status).toBe(201); expect(body).toMatchObject({ id: expect.any(String), name: 'New Person', - birthDate: '1990-01-01T00:00:00.000Z', + birthDate: '1990-01-01', + }); + }); + + it('should create a favorite person', async () => { + const { status, body } = await request(app) + .post(`/people`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ + name: 'New Favorite Person', + isFavorite: true, + }); + expect(status).toBe(201); + expect(body).toMatchObject({ + id: expect.any(String), + name: 'New Favorite Person', + isFavorite: true, }); }); }); @@ -216,6 +233,7 @@ describe('/people', () => { { key: 'name', type: 'string' }, { key: 'featureFaceAssetId', type: 'string' }, { key: 'isHidden', type: 'boolean value' }, + { key: 'isFavorite', type: 'boolean value' }, ]) { it(`should not allow null ${key}`, async () => { const { status, body } = await request(app) @@ -244,7 +262,7 @@ describe('/people', () => { .set('Authorization', `Bearer ${admin.accessToken}`) .send({ birthDate: '1990-01-01' }); expect(status).toBe(200); - expect(body).toMatchObject({ birthDate: '1990-01-01T00:00:00.000Z' }); + expect(body).toMatchObject({ birthDate: '1990-01-01' }); }); it('should clear a date of birth', async () => { @@ -255,6 +273,42 @@ describe('/people', () => { expect(status).toBe(200); expect(body).toMatchObject({ birthDate: null }); }); + + it('should set a color', async () => { + const { status, body } = await request(app) + .put(`/people/${visiblePerson.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ color: '#555' }); + expect(status).toBe(200); + expect(body).toMatchObject({ color: '#555' }); + }); + + it('should clear a color', async () => { + const { status, body } = await request(app) + .put(`/people/${visiblePerson.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ color: null }); + expect(status).toBe(200); + expect(body.color).toBeUndefined(); + }); + + it('should mark a person as favorite', async () => { + const person = await utils.createPerson(admin.accessToken, { + name: 'visible_person', + }); + + expect(person.isFavorite).toBe(false); + + const { status, body } = await request(app) + .put(`/people/${person.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ isFavorite: true }); + expect(status).toBe(200); + expect(body).toMatchObject({ isFavorite: true }); + + const person2 = await getPerson({ id: person.id }, { headers: asBearerAuth(admin.accessToken) }); + expect(person2).toMatchObject({ id: person.id, isFavorite: true }); + }); }); describe('POST /people/:id/merge', () => { diff --git a/e2e/src/api/specs/shared-link.e2e-spec.ts b/e2e/src/api/specs/shared-link.e2e-spec.ts index 45c906578d..154f190f53 100644 --- a/e2e/src/api/specs/shared-link.e2e-spec.ts +++ b/e2e/src/api/specs/shared-link.e2e-spec.ts @@ -89,13 +89,13 @@ describe('/shared-links', () => { await deleteUserAdmin({ id: user2.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) }); }); - describe('GET /share/${key}', () => { + describe('GET /share/:key', () => { it('should have correct asset count in meta tag for non-empty album', async () => { const resp = await request(shareUrl).get(`/${linkWithMetadata.key}`); expect(resp.status).toBe(200); expect(resp.header['content-type']).toContain('text/html'); expect(resp.text).toContain( - ``, + ``, ); }); @@ -103,14 +103,14 @@ describe('/shared-links', () => { const resp = await request(shareUrl).get(`/${linkWithAlbum.key}`); expect(resp.status).toBe(200); expect(resp.header['content-type']).toContain('text/html'); - expect(resp.text).toContain(``); + expect(resp.text).toContain(``); }); it('should have correct asset count in meta tag for shared asset', async () => { const resp = await request(shareUrl).get(`/${linkWithAssets.key}`); expect(resp.status).toBe(200); expect(resp.header['content-type']).toContain('text/html'); - expect(resp.text).toContain(``); + expect(resp.text).toContain(``); }); it('should have fqdn og:image meta tag for shared asset', async () => { @@ -139,7 +139,10 @@ describe('/shared-links', () => { expect(body).toEqual( expect.arrayContaining([ expect.objectContaining({ id: linkWithAlbum.id }), - expect.objectContaining({ id: linkWithAssets.id }), + expect.objectContaining({ + id: linkWithAssets.id, + assets: expect.arrayContaining([expect.objectContaining({ id: asset1.id })]), + }), expect.objectContaining({ id: linkWithPassword.id }), expect.objectContaining({ id: linkWithMetadata.id }), expect.objectContaining({ id: linkWithoutMetadata.id }), @@ -147,6 +150,30 @@ describe('/shared-links', () => { ); }); + it('should filter on albumId', async () => { + const { status, body } = await request(app) + .get(`/shared-links?albumId=${album.id}`) + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + expect(body).toHaveLength(2); + expect(body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: linkWithAlbum.id }), + expect.objectContaining({ id: linkWithPassword.id }), + ]), + ); + }); + + it('should find 0 albums', async () => { + const { status, body } = await request(app) + .get(`/shared-links?albumId=${uuidDto.notFound}`) + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + expect(body).toHaveLength(0); + }); + it('should not get shared links created by other users', async () => { const { status, body } = await request(app) .get('/shared-links') diff --git a/e2e/src/api/specs/trash.e2e-spec.ts b/e2e/src/api/specs/trash.e2e-spec.ts index 15b915ef2a..7a1a61f946 100644 --- a/e2e/src/api/specs/trash.e2e-spec.ts +++ b/e2e/src/api/specs/trash.e2e-spec.ts @@ -1,4 +1,4 @@ -import { LoginResponseDto, getAssetInfo, getAssetStatistics, scanLibrary } from '@immich/sdk'; +import { LoginResponseDto, getAssetInfo, getAssetStatistics } from '@immich/sdk'; import { existsSync } from 'node:fs'; import { Socket } from 'socket.io-client'; import { errorDto } from 'src/responses'; @@ -6,8 +6,6 @@ import { app, asBearerAuth, testAssetDir, testAssetDirInternal, utils } from 'sr import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -const scan = async (accessToken: string, id: string) => scanLibrary({ id }, { headers: asBearerAuth(accessToken) }); - describe('/trash', () => { let admin: LoginResponseDto; let ws: Socket; @@ -81,8 +79,7 @@ describe('/trash', () => { utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); expect(assets.items.length).toBe(1); @@ -90,8 +87,7 @@ describe('/trash', () => { await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] }); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const assetBefore = await utils.getAssetInfo(admin.accessToken, asset.id); expect(assetBefore).toMatchObject({ isTrashed: true, isOffline: true }); @@ -116,8 +112,7 @@ describe('/trash', () => { utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); expect(assets.items.length).toBe(1); @@ -125,8 +120,7 @@ describe('/trash', () => { await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] }); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const assetBefore = await utils.getAssetInfo(admin.accessToken, asset.id); expect(assetBefore).toMatchObject({ isTrashed: true, isOffline: true }); @@ -180,8 +174,7 @@ describe('/trash', () => { utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); expect(assets.count).toBe(1); @@ -189,9 +182,7 @@ describe('/trash', () => { await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] }); - await scan(admin.accessToken, library.id); - - await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.scan(admin.accessToken, library.id); const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true })); @@ -201,6 +192,8 @@ describe('/trash', () => { const after = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); expect(after).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true })); + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); }); }); @@ -238,7 +231,7 @@ describe('/trash', () => { utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - await scan(admin.accessToken, library.id); + await utils.scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); @@ -247,7 +240,7 @@ describe('/trash', () => { await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] }); - await scan(admin.accessToken, library.id); + await utils.scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); const before = await utils.getAssetInfo(admin.accessToken, assetId); @@ -261,6 +254,8 @@ describe('/trash', () => { const after = await utils.getAssetInfo(admin.accessToken, assetId); expect(after.isTrashed).toBe(true); + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); }); }); }); diff --git a/e2e/src/api/specs/user-admin.e2e-spec.ts b/e2e/src/api/specs/user-admin.e2e-spec.ts index 8a417387e7..9299e62b79 100644 --- a/e2e/src/api/specs/user-admin.e2e-spec.ts +++ b/e2e/src/api/specs/user-admin.e2e-spec.ts @@ -356,5 +356,24 @@ describe('/admin/users', () => { expect(status).toBe(403); expect(body).toEqual(errorDto.forbidden); }); + + it('should restore a user', async () => { + const user = await utils.userSetup(admin.accessToken, createUserDto.create('restore')); + + await deleteUserAdmin({ id: user.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) }); + + const { status, body } = await request(app) + .post(`/admin/users/${user.userId}/restore`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual( + expect.objectContaining({ + id: user.userId, + email: user.userEmail, + status: 'active', + deletedAt: null, + }), + ); + }); }); }); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index efd9ce76b9..7446bb708f 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -6,6 +6,8 @@ import { CheckExistingAssetsDto, CreateAlbumDto, CreateLibraryDto, + JobCommandDto, + JobName, MetadataSearchDto, Permission, PersonCreateDto, @@ -26,9 +28,12 @@ import { deleteAssets, getAllJobsStatus, getAssetInfo, + getConfig, getConfigDefaults, login, + scanLibrary, searchAssets, + sendJobCommand, setBaseUrl, signUpAdmin, tagAssets, @@ -117,6 +122,7 @@ const execPromise = promisify(exec); const onEvent = ({ event, id }: { event: EventType; id: string }) => { // console.log(`Received event: ${event} [id=${id}]`); const set = events[event]; + set.add(id); const idCallback = idCallbacks[id]; @@ -411,6 +417,8 @@ export const utils = { rmSync(path, { recursive: true }); }, + getSystemConfig: (accessToken: string) => getConfig({ headers: asBearerAuth(accessToken) }), + getAssetInfo: (accessToken: string, id: string) => getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }), checkExistingAssets: (accessToken: string, checkExistingAssetsDto: CheckExistingAssetsDto) => @@ -475,6 +483,9 @@ export const utils = { tagAssets: (accessToken: string, tagId: string, assetIds: string[]) => tagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }, { headers: asBearerAuth(accessToken) }), + jobCommand: async (accessToken: string, jobName: JobName, jobCommandDto: JobCommandDto) => + sendJobCommand({ id: jobName, jobCommandDto }, { headers: asBearerAuth(accessToken) }), + setAuthCookies: async (context: BrowserContext, accessToken: string, domain = '127.0.0.1') => await context.addCookies([ { @@ -547,6 +558,14 @@ export const utils = { await immichCli(['login', app, `${key.secret}`]); return key.secret; }, + + scan: async (accessToken: string, id: string) => { + await scanLibrary({ id }, { headers: asBearerAuth(accessToken) }); + + await utils.waitForQueueFinish(accessToken, 'library'); + await utils.waitForQueueFinish(accessToken, 'sidecar'); + await utils.waitForQueueFinish(accessToken, 'metadataExtraction'); + }, }; utils.initSdk(); diff --git a/i18n/af.json b/i18n/af.json index fb4ae82741..349626306f 100644 --- a/i18n/af.json +++ b/i18n/af.json @@ -20,7 +20,7 @@ "add_partner": "Voeg vennoot by", "add_path": "Voeg pad by", "add_photos": "Voeg foto's by", - "add_to": "Voeg na...", + "add_to": "Voeg by…", "add_to_album": "Voeg na album", "add_to_shared_album": "Voeg na gedeelde album", "add_url": "Voeg URL by", @@ -57,6 +57,23 @@ "exclusion_pattern_description": "Met uitsluitingspatrone kan jy lêers en vouers ignoreer wanneer jy jou biblioteek skandeer. Dit is nuttig as jy vouers het wat lêers bevat wat jy nie wil invoer nie, soos RAW-lêers.", "external_library_created_at": "Eksterne biblioteek (geskep op {date})", "external_library_management": "Eksterne Biblioteek-opsies", - "face_detection": "Gesigsopsporing" - } + "face_detection": "Gesig deteksie", + "failed_job_command": "Opdrag {command} het misluk vir werk: {job}", + "force_delete_user_warning": "WAARSKUWING: Dit sal onmiddellik die gebruiker en alle bates verwyder. Dit kan nie ontdoen word nie en die lêers kan nie herstel word nie.", + "forcing_refresh_library_files": "Forseer herlaai van alle biblioteeklêers", + "image_format": "Formaat", + "image_format_description": "WebP produseer kleiner lêers as JPEG, maar is stadiger om te enkodeer.", + "image_prefer_embedded_preview": "Verkies ingebedde voorskou", + "image_prefer_wide_gamut": "Verkies wye spektrum", + "image_preview_description": "Mediumgrootte prent met gestroopte metadata, wat gebruik word wanneer 'n enkele bate bekyk word en vir masjienleer", + "image_preview_quality_description": "Voorskou kwaliteit van 1-100. Hoër is beter, maar produseer groter lêers en kan app-reaksie verminder. Die stel van 'n lae waarde kan masjienleerkwaliteit beïnvloed.", + "image_preview_title": "Voorskou Instellings", + "image_quality": "Kwaliteit", + "image_resolution": "Resolusie", + "image_resolution_description": "Hoër resolusies kan meer detail bewaar, maar neem langer om te enkodeer, het groter lêergroottes en kan app-reaksie verminder.", + "image_settings": "Prent Instellings", + "image_settings_description": "Bestuur die kwaliteit en resolusie van gegenereerde beelde" + }, + "search_by_description": "Soek by beskrywing", + "search_by_description_example": "Stapdag in Sapa" } diff --git a/i18n/ar.json b/i18n/ar.json index 5c2b1a9506..66e4045606 100644 --- a/i18n/ar.json +++ b/i18n/ar.json @@ -41,6 +41,7 @@ "backup_settings": "إعدادات النسخ الاحتياطي", "backup_settings_description": "إدارة إعدادات النسخ الاحتياطي لقاعدة البيانات", "check_all": "اختر الكل", + "cleanup": "تنظيف", "cleared_jobs": "تم إخلاء مهام: {job}", "config_set_by_file": "الإعدادات حاليًا معينة عن طريق ملف الاعدادات", "confirm_delete_library": "هل أنت متأكد أنك تريد حذف مكتبة {library}؟", @@ -96,7 +97,7 @@ "library_scanning_enable_description": "تفعيل فحص المكتبة الدوري", "library_settings": "المكتبة الخارجية", "library_settings_description": "إدارة إعدادات المكتبة الخارجية", - "library_tasks_description": "قم بتنفيذ مهام المكتبة", + "library_tasks_description": "مسح المكتبات الخارجية للعثور على الأصول الجديدة و/أو المتغيرة", "library_watching_enable_description": "راقب المكتبات الخارجية لتتبع تغييرات الملفات", "library_watching_settings": "مراقبة المكتبات (تجريبي)", "library_watching_settings_description": "راقب تلقائيًا التغييرات في الملفات", @@ -131,7 +132,7 @@ "machine_learning_smart_search_description": "البحث عن الصور بشكل دلالي باستخدام تضمينات CLIP", "machine_learning_smart_search_enabled": "تفعيل البحث الذكي", "machine_learning_smart_search_enabled_description": "إذا تم تعطيله، فلن يتم ترميز الصور للبحث الذكي.", - "machine_learning_url_description": "عنوان URL لخادم التعلم الآلي. إذا تم توفير أكثر من عنوان URL، فسيتم محاولة الوصول إلى كل خادم على حدة حتى يستجيب أحد الخوادم بنجاح، بالترتيب من الأول إلى الأخير.", + "machine_learning_url_description": "عنوان URL لخادم التعلم الآلي. إذا تم توفير أكثر من عنوان URL واحد، سيتم محاولة الاتصال بكل خادم على حدة حتى يستجيب أحدهم بنجاح، بدءًا من الأول إلى الأخير. سيتم تجاهل الخوادم التي لا تستجيب مؤقتًا حتى تعود للعمل.", "manage_concurrency": "إدارة التزامن", "manage_log_settings": "إدارة إعدادات السجلات", "map_dark_style": "النمط الداكن", @@ -147,6 +148,8 @@ "map_settings": "الخريطة", "map_settings_description": "إدارة إعدادات الخريطة", "map_style_description": "عنوان URL لسمة الخريطة style.json", + "memory_cleanup_job": "تنظيف الذاكرة", + "memory_generate_job": "توليد الذاكرة", "metadata_extraction_job": "استخراج البيانات الوصفية", "metadata_extraction_job_description": "استخراج معلومات البيانات الوصفية من كل أصل، مثل إحداثيات الموقع, الوجوه والدقة", "metadata_faces_import_setting": "تمكين استيراد الوجه", @@ -219,7 +222,7 @@ "reset_settings_to_default": "إعادة ضبط الإعدادات إلى الوضع الافتراضي", "reset_settings_to_recent_saved": "إعادة ضبط الإعدادات إلى الإعدادات المحفوظة مؤخرًا", "scanning_library": "مسح المكتبة", - "search_jobs": "البحث عن وظائف...", + "search_jobs": "البحث عن وظائف…", "send_welcome_email": "إرسال بريد ترحيبي", "server_external_domain_settings": "إسم النطاق الخارجي", "server_external_domain_settings_description": "إسم النطاق لروابط المشاركة العامة، بما في ذلك http(s)://", @@ -250,8 +253,16 @@ "storage_template_user_label": "{label} هو تسمية التخزين الخاصة بالمستخدم", "system_settings": "إعدادات النظام", "tag_cleanup_job": "تنظيف العلامة", + "template_email_available_tags": "يمكنك استخدام المتغيرات التالية في القالب الخاص بك: {tags}", + "template_email_if_empty": "إذا كان القالب فارغا، فسيتم استخدام البريد الإلكتروني الافتراضي.", + "template_email_invite_album": "قالب دعوة الألبوم", "template_email_preview": "عرض مسبق", "template_email_settings": "نماذج البريد الالكتروني", + "template_email_settings_description": "إدارة قوالب إشعارات البريد الإلكتروني المخصصة", + "template_email_update_album": "تحديث قالب الألبوم", + "template_email_welcome": "قالب البريد الإلكتروني الترحيبي", + "template_settings": "قوالب الإشعارات", + "template_settings_description": "إدارة القوالب المخصصة للإشعارات.", "theme_custom_css_settings": "CSS مخصص", "theme_custom_css_settings_description": "أوراق الأنماط المتتالية تسمح بتخصيص تصميم Immich.", "theme_settings": "إعدادات السمة", @@ -281,6 +292,8 @@ "transcoding_constant_rate_factor": "عامل معدل الجودة الثابت (-crf)", "transcoding_constant_rate_factor_description": "مستوى جودة الفيديو. القيم النموذجية هي 23 لـ H.264، 28 لـ HEVC، 31 لـ VP9، و 35 لـ AV1. كلما كانت القيمة أقل كان ذلك أفضل، ولكن يؤدي إلى ملفات أكبر.", "transcoding_disabled_description": "لا تقم بتحويل أي مقاطع فيديو، قد تؤدي إلى عدم تشغيلها على بعض العملاء", + "transcoding_encoding_options": "خيارات الترميز", + "transcoding_encoding_options_description": "اضبط برامج الترميز والدقة والجودة والخيارات الأخرى لمقاطع الفيديو المشفرة", "transcoding_hardware_acceleration": "التسريع العتادي", "transcoding_hardware_acceleration_description": "تجريبي؛ أسرع بكثير، ولكن ستكون جودتها أقل عند نفس معدل البت", "transcoding_hardware_decoding": "فك تشفير الأجهزة", @@ -293,6 +306,8 @@ "transcoding_max_keyframe_interval": "الحد الأقصى للفاصل الزمني للإطار الرئيسي", "transcoding_max_keyframe_interval_description": "يضبط الحد الأقصى لمسافة الإطار بين الإطارات الرئيسية. تؤدي القيم المنخفضة إلى زيادة سوء كفاءة الضغط، ولكنها تعمل على تحسين أوقات البحث وقد تعمل على تحسين الجودة في المشاهد ذات الحركة السريعة. 0 يضبط هذه القيمة تلقائيًا.", "transcoding_optimal_description": "مقاطع الفيديو ذات الدقة الأعلى من الدقة المستهدفة أو بتنسيق غير مقبول", + "transcoding_policy": "سياسة تحويل الترميز", + "transcoding_policy_description": "اضبط متى سيتم تحويل ترميز الفيديو", "transcoding_preferred_hardware_device": "الجهاز المفضل", "transcoding_preferred_hardware_device_description": "ينطبق فقط على VAAPI وQSV. يضبط عقدة dri المستخدمة لتحويل ترميز الأجهزة.", "transcoding_preset_preset": "الضبط المُسبق (-preset)", @@ -301,7 +316,7 @@ "transcoding_reference_frames_description": "عدد الإطارات التي يجب الرجوع إليها عند ضغط إطار معين. تعمل القيم الأعلى على تحسين كفاءة الضغط، ولكنها تبطئ عملية التشفير. 0 يضبط هذه القيمة تلقائيًا.", "transcoding_required_description": "فقط مقاطع الفيديو ذات التنسيق غير المقبول", "transcoding_settings": "إعدادات تحويل ترميز الفيديو", - "transcoding_settings_description": "إدارة معلومات الدقة والترميز لملفات الفيديو", + "transcoding_settings_description": "إدارة مقاطع الفيديو التي يجب تحويل ترميزها وكيفية معالجتها", "transcoding_target_resolution": "القرار المستهدف", "transcoding_target_resolution_description": "يمكن أن تحافظ الدقة الأعلى على المزيد من التفاصيل ولكنها تستغرق وقتًا أطول للتشفير، ولها أحجام ملفات أكبر، ويمكن أن تقلل من استجابة التطبيق.", "transcoding_temporal_aq": "التكميم التكيفي الزمني", @@ -314,7 +329,7 @@ "transcoding_transcode_policy_description": "سياسة تحديد متى يجب ترميز الفيديو. سيتم دائمًا ترميز مقاطع الفيديو HDR (ما لم يتم تعطيل الترميز).", "transcoding_two_pass_encoding": "الترميز بمرورين", "transcoding_two_pass_encoding_setting_description": "ترميز بمرورين لإنتاج مقاطع فيديو بترميز أفضل. عند تمكين الحد الأقصى لمعدل البت (مطلوب لكي يعمل مع H.264 و HEVC)، يستخدم هذا الوضع نطاق معدل البت استنادًا إلى الحد الأقصى لمعدل البت ويتجاهل CRF. بالنسبة لـ VP9، يمكن استخدام CRF إذا تم تعطيل الحد الأقصى لمعدل البت.", - "transcoding_video_codec": "كود الفيديو", + "transcoding_video_codec": "ترميز الفيديو", "transcoding_video_codec_description": "يتمتع VP9 بكفاءة عالية وتوافق مع الويب، ولكنه يستغرق وقتًا أطول في تحويل التعليمات البرمجية. يعمل HEVC بشكل مشابه، لكن توافقه مع الويب أقل. H.264 متوافق على نطاق واسع وسريع في تحويل التعليمات البرمجية، ولكنه ينتج ملفات أكبر بكثير. AV1 هو برنامج الترميز الأكثر كفاءة ولكنه يفتقر إلى الدعم على الأجهزة القديمة.", "trash_enabled_description": "تفعيل ميزات سلة المهملات", "trash_number_of_days": "عدد الأيام", @@ -379,6 +394,7 @@ "allow_edits": "إسمح بالتعديل", "allow_public_user_to_download": "السماح لأي مستخدم عام بالتنزيل", "allow_public_user_to_upload": "السماح للمستخدم العام بالرفع", + "alt_text_qr_code": "صورة رمز الاستجابة السريعة (QR)", "anti_clockwise": "عكس اتجاه عقارب الساعة", "api_key": "مفتاح واجهة برمجة التطبيقات", "api_key_description": "سيتم عرض هذه القيمة مرة واحدة فقط. يرجى التأكد من نسخها قبل إغلاق النافذة.", @@ -394,17 +410,17 @@ "are_these_the_same_person": "هل هؤلاء هم نفس الشخص؟", "are_you_sure_to_do_this": "هل انت متأكد من أنك تريد أن تفعل هذا؟", "asset_added_to_album": "تمت إضافته إلى الألبوم", - "asset_adding_to_album": "جارٍ الإضافة إلى الألبوم...", + "asset_adding_to_album": "جارٍ الإضافة إلى الألبوم…", "asset_description_updated": "تم تحديث وصف المحتوى", "asset_filename_is_offline": "الأصل {filename} غير متصل", "asset_has_unassigned_faces": "يحتوي الأصل على وجوه غير مخصصة", - "asset_hashing": "التجزئة...", + "asset_hashing": "التجزئة…", "asset_offline": "المحتوى غير اتصال", "asset_offline_description": "لم يعد هذا الأصل الخارجي موجودًا على القرص. يرجى الاتصال بمسؤول Immich للحصول على المساعدة.", "asset_skipped": "تم تخطيه", "asset_skipped_in_trash": "في سلة المهملات", "asset_uploaded": "تم الرفع", - "asset_uploading": "جارٍ الرفع...", + "asset_uploading": "جارٍ الرفع…", "assets": "المحتويات", "assets_added_count": "تمت إضافة {count, plural, one {# محتوى} other {# محتويات}}", "assets_added_to_album_count": "تمت إضافة {count, plural, one {# الأصل} other {# الأصول}} إلى الألبوم", @@ -469,6 +485,7 @@ "comments_are_disabled": "التعليقات معطلة", "confirm": "تأكيد", "confirm_admin_password": "تأكيد كلمة مرور المسؤول", + "confirm_delete_face": "هل أنت متأكد من حذف وجه {name} من الأصول؟", "confirm_delete_shared_link": "هل أنت متأكد أنك تريد حذف هذا الرابط المشترك؟", "confirm_keep_this_delete_others": "سيتم حذف جميع الأصول الأخرى في المجموعة باستثناء هذا الأصل. هل أنت متأكد من أنك تريد المتابعة؟", "confirm_password": "تأكيد كلمة المرور", @@ -511,12 +528,17 @@ "date_range": "نطاق الموعد", "day": "يوم", "deduplicate_all": "إلغاء تكرار الكل", + "deduplication_criteria_1": "حجم الصورة بوحدات البايت", + "deduplication_criteria_2": "عدد بيانات EXIF", + "deduplication_info": "معلومات إلغاء البيانات المكررة", + "deduplication_info_description": "لتحديد الأصول مسبقا تلقائيا وإزالة التكرارات بكميات كبيرة، ننظر إلى:", "default_locale": "اللغة الافتراضية", "default_locale_description": "تنسيق التواريخ والأرقام بناءً على لغة المتصفح الخاص بك", "delete": "حذف", "delete_album": "حذف الألبوم", "delete_api_key_prompt": "هل أنت متأكد أنك تريد حذف مفتاح API هذا؟", "delete_duplicates_confirmation": "هل أنت متأكد أنك تريد حذف هذه التكرارات نهائيًا؟", + "delete_face": "حذف الوجه", "delete_key": "حذف المفتاح", "delete_library": "حذف المكتبة", "delete_link": "حذف الرابط", @@ -584,6 +606,7 @@ "enabled": "مفعل", "end_date": "تاريخ الإنتهاء", "error": "خطأ", + "error_delete_face": "حدث خطأ في حذف الوجه من الأصول", "error_loading_image": "حدث خطأ أثناء تحميل الصورة", "error_title": "خطأ - حدث خللٌ ما", "errors": { @@ -726,6 +749,7 @@ "external": "خارجي", "external_libraries": "المكتبات الخارجية", "face_unassigned": "غير معين", + "failed_to_load_assets": "فشل تحميل الأصول", "favorite": "مفضل", "favorite_or_unfavorite_photo": "تفضيل أو إلغاء تفضيل الصورة", "favorites": "المفضلة", @@ -746,10 +770,13 @@ "get_help": "الحصول على المساعدة", "getting_started": "البدء", "go_back": "الرجوع للخلف", + "go_to_folder": "اذهب إلى المجلد", "go_to_search": "اذهب إلى البحث", "group_albums_by": "تجميع الألبومات حسب...", + "group_country": "مجموعة البلد", "group_no": "بدون تجميع", "group_owner": "تجميع حسب المالك", + "group_places_by": "تجميع الأماكن حسب...", "group_year": "تجميع حسب السنة", "has_quota": "محدد بحصة", "hi_user": "مرحبا {name} ({email})", @@ -782,6 +809,7 @@ "include_shared_albums": "تضمين الألبومات المشتركة", "include_shared_partner_assets": "تضمين محتويات الشريك المشتركة", "individual_share": "حصة فردية", + "individual_shares": "المشاركات الفردية", "info": "معلومات", "interval": { "day_at_onepm": "كل يوم الساعة الواحدة ظهرا", @@ -804,6 +832,7 @@ "latest_version": "احدث اصدار", "latitude": "خط العرض", "leave": "مغادرة", + "lens_model": "نموذج العدسات", "let_others_respond": "دع الآخرين يستجيبون", "level": "المستوى", "library": "مكتبة", @@ -862,6 +891,7 @@ "month": "شهر", "more": "المزيد", "moved_to_trash": "تم النقل إلى سلة المهملات", + "mute_memories": "كتم الذكريات", "my_albums": "ألبوماتي", "name": "الاسم", "name_or_nickname": "الاسم أو اللقب", @@ -966,6 +996,7 @@ "pick_a_location": "اختر موقعًا", "place": "مكان", "places": "الأماكن", + "places_count": "{count, plural, one {{count, number} مكان} other {{count, number} أماكن}}", "play": "تشغيل", "play_memories": "تشغيل الذكريات", "play_motion_photo": "تشغيل الصور المتحركة", @@ -1025,6 +1056,7 @@ "reassigned_assets_to_new_person": "تمت إعادة تعيين {count, plural, one {# المحتوى} other {# المحتويات}} إلى شخص جديد", "reassing_hint": "تعيين المحتويات المحددة لشخص موجود", "recent": "حديث", + "recent-albums": "ألبومات الحديثة", "recent_searches": "عمليات البحث الأخيرة", "refresh": "تحديث", "refresh_encoded_videos": "تحديث مقاطع الفيديو المشفرة", @@ -1046,11 +1078,14 @@ "remove_from_album": "إزالة من الألبوم", "remove_from_favorites": "إزالة من المفضلة", "remove_from_shared_link": "إزالة من الرابط المشترك", + "remove_url": "إزالة عنوان URL", "remove_user": "إزالة المستخدم", "removed_api_key": "تم إزالة مفتاح API: {name}", "removed_from_archive": "تمت إزالتها من الأرشيف", "removed_from_favorites": "تمت الإزالة من المفضلة", "removed_from_favorites_count": "{count, plural, other {أُزيلت #}} من التفضيلات", + "removed_memory": "الذاكرة المحذوفة", + "removed_photo_from_memory": "تم إزالة الصورة من الذاكرة", "removed_tagged_assets": "تمت إزالة العلامة من {count, plural, one {# الأصل} other {# الأصول}}", "rename": "إعادة تسمية", "repair": "إصلاح", @@ -1059,6 +1094,7 @@ "repository": "المستودع", "require_password": "يتطلب كلمة المرور", "require_user_to_change_password_on_first_login": "مطالبة المستخدم بتغيير كلمة المرور عند تسجيل الدخول لأول مرة", + "rescan": "إعادة المسح", "reset": "إعادة ضبط", "reset_password": "إعادة تعيين كلمة المرور", "reset_people_visibility": "إعادة ضبط ظهور الأشخاص", @@ -1084,56 +1120,61 @@ "scan_library": "مسح", "scan_settings": "إعدادات الفحص", "scanning_for_album": "جارٍ الفحص عن ألبوم...", - "search": "بحث", - "search_albums": "بحث في الألبومات", + "search": "البحث", + "search_albums": "البحث في الألبومات", "search_by_context": "البحث حسب السياق", - "search_by_filename": "إبحث بإسم الملف أو نوعه", + "search_by_description": "البحث حسب الوصف", + "search_by_description_example": "يوم المشي لمسافات طويلة في سابا", + "search_by_filename": "البحث بإسم الملف أو نوعه", "search_by_filename_example": "كـ IMG_1234.JPG أو PNG", "search_camera_make": "البحث حسب الشركة المصنعة للكاميرا...", "search_camera_model": "البحث حسب موديل الكاميرا...", "search_city": "البحث حسب المدينة...", "search_country": "البحث حسب الدولة...", + "search_for": "البحث عن", "search_for_existing_person": "البحث عن شخص موجود", "search_no_people": "لا يوجد أشخاص", "search_no_people_named": "لا يوجد أشخاص بالاسم \"{name}\"", "search_options": "خيارات البحث", "search_people": "البحث عن الأشخاص", "search_places": "البحث عن الأماكن", + "search_rating": "البحث حسب التقييم...", "search_settings": "إعدادات البحث", "search_state": "البحث حسب الولاية...", "search_tags": "البحث عن العلامات...", "search_timezone": "البحث حسب المنطقة الزمنية...", "search_type": "نوع البحث", - "search_your_photos": "ابحث عن صورك", + "search_your_photos": "البحث عن صورك", "searching_locales": "جارٍ البحث في اللغات...", "second": "ثانية", "see_all_people": "عرض جميع الأشخاص", - "select_album_cover": "حدد غلاف الألبوم", + "select_album_cover": "تحديد غلاف الألبوم", "select_all": "تحديد الكل", "select_all_duplicates": "تحديد جميع النسخ المكررة", - "select_avatar_color": "حدد لون الصورة الشخصية", - "select_face": "اختيار وجه", - "select_featured_photo": "حدد الصورة المميزة", - "select_from_computer": "اختر من الجهاز", - "select_keep_all": "حدد الاحتفاظ بالكل", - "select_library_owner": "اختر مالِك المكتبة", - "select_new_face": "اختيار وجه جديد", - "select_photos": "حدد الصور", - "select_trash_all": "حدّد حذف الكلِ", - "selected": "المُحدّد", + "select_avatar_color": "تحديد لون الصورة الشخصية", + "select_face": "تحديد وجه", + "select_featured_photo": "تحديد الصورة المميزة", + "select_from_computer": "تحديد من الحاسب الآلي", + "select_keep_all": "تحديد الأحتفاظ بالكل", + "select_library_owner": "تحديد مالِك المكتبة", + "select_new_face": "تحديد وجه جديد", + "select_photos": "تحديد الصور", + "select_trash_all": "تحديد حذف الكلِ", + "selected": "التحديد", "selected_count": "{count, plural, other {# محددة }}", - "send_message": "أرسل رسالة", - "send_welcome_email": "أرسل بريدًا إلكترونيًا ترحيبيًا", + "send_message": "‏إرسال رسالة", + "send_welcome_email": "إرسال بريدًا إلكترونيًا ترحيبيًا", "server_offline": "الخادم غير متصل", "server_online": "الخادم متصل", "server_stats": "إحصائيات الخادم", "server_version": "إصدار الخادم", - "set": "تعيين", - "set_as_album_cover": "تعيين كغلاف للألبوم", - "set_as_profile_picture": "تعيين كصورة الملف الشخصي", + "set": "‏تحديد", + "set_as_album_cover": "تحديد كغلاف للألبوم", + "set_as_featured_photo": "تحديد كصورة مميزة", + "set_as_profile_picture": "تحديد كصورة الملف الشخصي", "set_date_of_birth": "تحديد تاريخ الميلاد", - "set_profile_picture": "تعيين صورة الملف الشخصي", - "set_slideshow_to_fullscreen": "اضبط عرض الشرائح على وضع ملء الشاشة", + "set_profile_picture": "تحديد صورة الملف الشخصي", + "set_slideshow_to_fullscreen": "تحديد عرض الشرائح على وضع ملء الشاشة", "settings": "الإعدادات", "settings_saved": "تم حفظ الإعدادات", "share": "مشاركة", @@ -1144,6 +1185,7 @@ "shared_from_partner": "صور من {partner}", "shared_link_options": "خيارات الرابط المشترك", "shared_links": "روابط مشتركة", + "shared_links_description": "وصف الروابط المشتركة", "shared_photos_and_videos_count": "{assetCount, plural, other {# الصور ومقاطع الفيديو المُشارَكة.}}", "shared_with_partner": "تمت المشاركة مع {partner}", "sharing": "مشاركة", @@ -1155,17 +1197,18 @@ "show_all_people": "إظهار جميع الأشخاص", "show_and_hide_people": "إظهار وإخفاء الأشخاص", "show_file_location": "إظهار موقع الملف", - "show_gallery": "عرض المعرض", + "show_gallery": "إظهار المعرض", "show_hidden_people": "إظهار الأشخاص المخفيين", - "show_in_timeline": "عرض في المخطط الزمني", - "show_in_timeline_setting_description": "عرض الصور ومقاطع الفيديو من هذا المستخدم في المخطط الزمني الخاص بك", + "show_in_timeline": "إظهار في المخطط الزمني", + "show_in_timeline_setting_description": "إظهار الصور ومقاطع الفيديو من هذا المستخدم في المخطط الزمني الخاص بك", "show_keyboard_shortcuts": "إظهار اختصارات لوحة المفاتيح", - "show_metadata": "عرض البيانات الوصفية", + "show_metadata": "إظهار البيانات الوصفية", "show_or_hide_info": "إظهار أو إخفاء المعلومات", - "show_password": "عرض كلمة المرور", + "show_password": "إظهار كلمة المرور", "show_person_options": "إظهار خيارات الشخص", "show_progress_bar": "إظهار شريط التقدم", "show_search_options": "إظهار خيارات البحث", + "show_shared_links": "عرض الروابط المشتركة", "show_slideshow_transition": "إظهار انتقال عرض الشرائح", "show_supporter_badge": "شارة المؤيد", "show_supporter_badge_description": "إظهار شارة المؤيد", @@ -1173,7 +1216,7 @@ "sidebar": "الشريط الجانبي", "sidebar_display_description": "عرض رابط للعرض في الشريط الجانبي", "sign_out": "خروج", - "sign_up": "تسجيل", + "sign_up": "التسجيل", "size": "الحجم", "skip_to_content": "تخطي إلى المحتوى", "skip_to_folders": "تخطي إلى المجلدات", @@ -1185,6 +1228,7 @@ "sort_items": "عدد العناصر", "sort_modified": "تم تعديل التاريخ", "sort_oldest": "أقدم صورة", + "sort_people_by_similarity": "رتب الأشخاص حسب التشابه", "sort_recent": "أحدث صورة", "sort_title": "العنوان", "source": "المصدر", @@ -1218,6 +1262,7 @@ "tag_created": "تم إنشاء العلامة: {tag}", "tag_feature_description": "تصفح الصور ومقاطع الفيديو المجمعة حسب مواضيع العلامات المنطقية", "tag_not_found_question": "لا يمكن العثور على علامة؟ قم بإنشاء علامة جديدة.", + "tag_people": "علِّم الأشخاص", "tag_updated": "تم تحديث العلامة: {tag}", "tagged_assets": "تم وضع علامة {count, plural, one {# asset} other {# assets}}", "tags": "العلامات", @@ -1252,11 +1297,13 @@ "unfavorite": "أزل التفضيل", "unhide_person": "أظهر الشخص", "unknown": "غير معروف", + "unknown_country": "بلد غير معروف", "unknown_year": "سنة غير معروفة", "unlimited": "غير محدود", "unlink_motion_video": "إلغاء ربط فيديو الحركة", "unlink_oauth": "إلغاء ربط OAuth", "unlinked_oauth_account": "تم إلغاء ربط حساب OAuth", + "unmute_memories": "تشغيل الصوت للذكريات", "unnamed_album": "ألبوم بلا إسم", "unnamed_album_delete_confirmation": "هل أنت متأكد أنك تريد حذف هذا الألبوم؟", "unnamed_share": "مشاركة بلا إسم", @@ -1310,6 +1357,7 @@ "view_all": "عرض الكل", "view_all_users": "عرض كافة المستخدمين", "view_in_timeline": "عرض في الجدول الزمني", + "view_link": "عرض الرابط", "view_links": "عرض الروابط", "view_name": "عرض", "view_next_asset": "عرض المحتوى التالي", diff --git a/i18n/bg.json b/i18n/bg.json index 47c1a82508..72948d8d50 100644 --- a/i18n/bg.json +++ b/i18n/bg.json @@ -20,7 +20,7 @@ "add_partner": "Добавете партньор", "add_path": "Добави път", "add_photos": "Добавете снимки", - "add_to": "Добави към...", + "add_to": "Добави към…", "add_to_album": "Добави към албум", "add_to_shared_album": "Добави към споделен албум", "add_url": "Добави URL", @@ -41,6 +41,7 @@ "backup_settings": "Настройка на резервни копия", "backup_settings_description": "Управление на настройките за резервно копие на базата данни", "check_all": "Провери всичко", + "cleanup": "Почистване", "cleared_jobs": "Изчистени задачи от тип: {job}", "config_set_by_file": "Конфигурацията е зададена от файл", "confirm_delete_library": "Сигурни ли сте че искате да изтриете библиотеката - {library} ?", @@ -96,7 +97,7 @@ "library_scanning_enable_description": "Включване на периодичното сканиране на библиотеката", "library_settings": "Външна библиотека", "library_settings_description": "Управление на настройките за външна библиотека", - "library_tasks_description": "Извършване на задачи за библиотеката", + "library_tasks_description": "Сканирайте външни библиотеки за нови и/или променени активи", "library_watching_enable_description": "Наблюдаване за промяна на файловете във външната библиотека", "library_watching_settings": "Наблюдаване на библиотеката (ЕКСПЕРИМЕНТАЛНО)", "library_watching_settings_description": "Автоматично наблюдавай за променени файлове", @@ -131,7 +132,7 @@ "machine_learning_smart_search_description": "Семантично търсене на изображения с помощта на CLIP вграждания", "machine_learning_smart_search_enabled": "Включване на Интелигентно Търсене", "machine_learning_smart_search_enabled_description": "Ако е деактивирано, изображенията няма да бъдат кодирани за Интелигентно Търсене.", - "machine_learning_url_description": "URL на сървъра за машинно обучение. Ако са предоставени повече от един URL, всеки сървър ще бъде опитан един по един, докато един не отговори успешно, в реда от първия до последния.", + "machine_learning_url_description": "URL на сървъра за машинно обучение. Ако са предоставени повече от един URL, всеки сървър ще бъде опитан един по един, докато един отговори успешно, в реда от първия до последния. Сървъри, които не отговорят, ще бъдат временно игнорирани, докато не се върнат онлайн.", "manage_concurrency": "Управление на паралелност", "manage_log_settings": "Управление на настройките на записване", "map_dark_style": "Тъмен стил", @@ -147,6 +148,8 @@ "map_settings": "Карта", "map_settings_description": "Управление на настройките на картата", "map_style_description": "URL адрес към файл \"style.json\" за задаване на стил на картата", + "memory_cleanup_job": "Почистване на паметта", + "memory_generate_job": "Генериране на паметта", "metadata_extraction_job": "Извличане на метаданни", "metadata_extraction_job_description": "Извличане на метаданни от всеки от елемент, като GPS локация, лица и резолюция на файловете", "metadata_faces_import_setting": "Включи импорт на лице", @@ -219,7 +222,7 @@ "reset_settings_to_default": "Възстановяване на настройките по подразбиране", "reset_settings_to_recent_saved": "Възстановяване на настройките до последните запазени настройки", "scanning_library": "Сканиране на библиотеката", - "search_jobs": "Търсене на задачи...", + "search_jobs": "Търсене на задачи…", "send_welcome_email": "Изпращане на имейл за добре дошли", "server_external_domain_settings": "Външен домейн", "server_external_domain_settings_description": "Домейн за публични споделени връзки, включително http(s)://", @@ -299,7 +302,7 @@ "transcoding_max_b_frames": "Максимални B-фрейма", "transcoding_max_b_frames_description": "По-високите стойности подобряват ефективността на компресията, но забавят разкодирането. Може да не е съвместим с хардуерното ускорение на по-стари устройства. 0 деактивира B-фрейма, докато -1 задава тази стойност автоматично.", "transcoding_max_bitrate": "Максимален битрейт", - "transcoding_max_bitrate_description": "Задаването на максимален битрейт може да направи размерите на файловете по-предвидими при незначителни разлики за качеството. При 720p типичните стойности са 2600k за VP9 или HEVC или 4500k за H.264. Деактивирано, ако е зададено на 0.", + "transcoding_max_bitrate_description": "Задаването на максимален битрейт може да направи размерите на файловете по-предвидими при незначителни разлики за качеството. При 720p типичните стойности са 2600 kbit/s за VP9 или HEVC или 4500 kbit/s за H.264. Деактивирано, ако е зададено на 0.", "transcoding_max_keyframe_interval": "Максимален интервал между ключовите кадри", "transcoding_max_keyframe_interval_description": "Задава максималното разстояние между ключовите кадри. По-ниските стойности влошават ефективността на компресията, но подобряват времето за търсене и могат да подобрят качеството в сцени с бързо движение. 0 задава тази стойност автоматично.", "transcoding_optimal_description": "Видеоклипове с по-висока от целевата разделителна способност или не в приетия формат", @@ -391,6 +394,7 @@ "allow_edits": "Позволяване на редакции", "allow_public_user_to_download": "Позволете на публичен потребител да може да изтегля", "allow_public_user_to_upload": "Позволете на публичния потребител да може да качва", + "alt_text_qr_code": "Изображение на QR код", "anti_clockwise": "Обратно на часовниковата стрелка", "api_key": "API ключ", "api_key_description": "Тази стойност ще бъде показана само веднъж. Моля, не забравяйте да го копирате, преди да затворите прозореца.", @@ -406,17 +410,17 @@ "are_these_the_same_person": "Това едно и също лице ли е?", "are_you_sure_to_do_this": "Сигурни ли сте, че искате да направите това?", "asset_added_to_album": "Добавено в албум", - "asset_adding_to_album": "Добавяне в албум...", + "asset_adding_to_album": "Добавяне в албум…", "asset_description_updated": "Описанието на елемента е обновено", "asset_filename_is_offline": "Активът {filename} е офлайн", "asset_has_unassigned_faces": "Елементът има незададени лица", - "asset_hashing": "Хеширане...", + "asset_hashing": "Хеширане…", "asset_offline": "Елементът е офлайн", "asset_offline_description": "Този външен актив вече не се намира на диска. Моля, свържете се с администратора на Immich за помощ.", "asset_skipped": "Пропуснато", "asset_skipped_in_trash": "В кошчето", "asset_uploaded": "Качено", - "asset_uploading": "Качване...", + "asset_uploading": "Качване…", "assets": "Елементи", "assets_added_count": "Добавено {count, plural, one {# asset} other {# assets}}", "assets_added_to_album_count": "Добавен(и) са {count, plural, one {# актив} other {# актива}} в албума", @@ -481,6 +485,7 @@ "comments_are_disabled": "Коментарите са деактивирани", "confirm": "Потвърди", "confirm_admin_password": "Потвърждаване на паролата на администратора", + "confirm_delete_face": "Сигурни ли сте, че искате да изтриете лицето на {name} от актива?", "confirm_delete_shared_link": "Сигурни ли сте, че искате да изтриете тази споделена връзка?", "confirm_keep_this_delete_others": "Всички останали файлове в стека ще бъдат изтрити, с изключение на този файл. Сигурни ли сте, че искате да продължите?", "confirm_password": "Потвърдете паролата", @@ -533,6 +538,7 @@ "delete_album": "Изтрий албум", "delete_api_key_prompt": "Сигурни ли сте, че искате да изтриете този API ключ?", "delete_duplicates_confirmation": "Сигурни ли сте, че искате да изтриете окончателно тези дубликати?", + "delete_face": "Изтрий лице", "delete_key": "Изтрий ключ", "delete_library": "Изтрий библиотека", "delete_link": "Изтрий линк", @@ -600,6 +606,7 @@ "enabled": "Включено", "end_date": "Крайна дата", "error": "Грешка", + "error_delete_face": "Грешка при изтриване на лице от актива", "error_loading_image": "Грешка при зареждане на изображението", "error_title": "Грешка - нещо се обърка", "errors": { @@ -766,8 +773,10 @@ "go_to_folder": "Отиди в папката", "go_to_search": "Преминаване към търсене", "group_albums_by": "Групирай албум по...", + "group_country": "Групирай по държава", "group_no": "Няма група", "group_owner": "Групиране по собственик", + "group_places_by": "Групирай места по…", "group_year": "Групиране по година", "has_quota": "Лимит", "hi_user": "Здравей, {name} {email}", @@ -800,6 +809,7 @@ "include_shared_albums": "Включване на споделени албуми", "include_shared_partner_assets": "Включване на споделените с партньор елементи", "individual_share": "Индивидуално споделяне", + "individual_shares": "Индивидуални споделяния", "info": "Информация", "interval": { "day_at_onepm": "Всеки ден в 13:00", @@ -822,6 +832,7 @@ "latest_version": "Последна версия", "latitude": "Ширина", "leave": "Излез", + "lens_model": "Модел леща", "let_others_respond": "Позволете на другите да отговорят", "level": "Ниво", "library": "Библиотека", @@ -880,6 +891,7 @@ "month": "Месец", "more": "Още", "moved_to_trash": "Преместено в кошчето", + "mute_memories": "Изключване на звука на спомените", "my_albums": "Мои албуми", "name": "Име", "name_or_nickname": "Име или прякор", @@ -984,6 +996,7 @@ "pick_a_location": "Избери локация", "place": "Местоположение", "places": "Местоположения", + "places_count": "{count, plural, one {{count, number} Място} other {{count, number} Места}}", "play": "Възпроизвеждане", "play_memories": "Възпроизвеждане на спомени", "play_motion_photo": "Възпроизведи Motion Photo", @@ -1067,10 +1080,12 @@ "remove_from_shared_link": "Премахни от споделения линк", "remove_url": "Премахни URL", "remove_user": "Премахни потребителя", - "removed_api_key": "Премахни API Key: {name}", - "removed_from_archive": "Премахни от Архива", - "removed_from_favorites": "Премахнато от Любими", + "removed_api_key": "Премахат API ключ: {name}", + "removed_from_archive": "Премахни от архива", + "removed_from_favorites": "Премахнато от любими", "removed_from_favorites_count": "{count, plural, other {Премахнати #}} от Любими", + "removed_memory": "Премахнат спомен", + "removed_photo_from_memory": "Премахната снимка от спомен", "removed_tagged_assets": "Премахнат е етикетът от {count, plural, one {# елемент} other {# елемента}}", "rename": "Преименувай", "repair": "Поправи", @@ -1079,6 +1094,7 @@ "repository": "Хранилище", "require_password": "Изискай парола", "require_user_to_change_password_on_first_login": "Изисквай потребителят да промени паролата си при първото влизане", + "rescan": "Пресканиране", "reset": "Нулиране", "reset_password": "Нулиране на паролата", "reset_people_visibility": "Нулиране на видимостта на хората", @@ -1107,18 +1123,22 @@ "search": "Търсене", "search_albums": "Търси албуми", "search_by_context": "Търси по контекст", + "search_by_description": "Търси по описание", + "search_by_description_example": "Разходка в Сапа", "search_by_filename": "Търси по име на файла или разширение", "search_by_filename_example": "например IMG_1234.JPG или PNG", "search_camera_make": "Търси производител на камерата...", "search_camera_model": "Търси модел на камерата...", "search_city": "Търси град...", "search_country": "Търси държава...", + "search_for": "Търси за", "search_for_existing_person": "Търси съществуващ човек", "search_no_people": "Няма хора", "search_no_people_named": "Няма хора на име \"{name}\"", "search_options": "Опции за търсене", "search_people": "Търсете на хора", "search_places": "Търсене на места", + "search_rating": "Търси по рейтинг…", "search_settings": "Търсене на настройки", "search_state": "Търсене на щат...", "search_tags": "Търсене на етикети...", @@ -1165,6 +1185,7 @@ "shared_from_partner": "Снимки от {partner}", "shared_link_options": "Опции за споделена връзка", "shared_links": "Споделени връзки", + "shared_links_description": "Сподели снимки и видеа с линк", "shared_photos_and_videos_count": "{assetCount, plural, other {# споделени снимки и видеа.}}", "shared_with_partner": "Споделено с {partner}", "sharing": "Споделени", @@ -1187,6 +1208,7 @@ "show_person_options": "Показване на опции за лица", "show_progress_bar": "Показване на прогрес бара", "show_search_options": "Показване на опциите за търсене", + "show_shared_links": "Покажи споделени линкове", "show_slideshow_transition": "Покажи прехода на слайдшоуто", "show_supporter_badge": "Значка поддръжник", "show_supporter_badge_description": "Покажи значка поддръжник", @@ -1240,6 +1262,7 @@ "tag_created": "Създаден етикет: {tag}", "tag_feature_description": "Разглеждане на снимки и видеоклипове, групирани по теми с логически тагове", "tag_not_found_question": "Не можете да намерите етикет? Създайте такъв тук", + "tag_people": "Отбележи Хора", "tag_updated": "Актуализиран етикет: {tag}", "tagged_assets": "Тагнати {count, plural, one {# елемент} other {# елементи}}", "tags": "Етикет", @@ -1274,11 +1297,13 @@ "unfavorite": "Премахване от любимите", "unhide_person": "Покажи отново човека", "unknown": "Неизвестно", + "unknown_country": "Непозната Държава", "unknown_year": "Неизвестна година", "unlimited": "Неограничено", "unlink_motion_video": "Премахни връзката с видео", "unlink_oauth": "Премахни OAuth", "unlinked_oauth_account": "Премахни OAuth акаунт", + "unmute_memories": "Включване на звука на спомените", "unnamed_album": "Албум без име", "unnamed_album_delete_confirmation": "Сигурни ли сте, че искате да изтриете този албум?", "unnamed_share": "Споделяне без име", @@ -1332,6 +1357,7 @@ "view_all": "Преглед на всички", "view_all_users": "Преглед на всички потребители", "view_in_timeline": "Покажи във времева линия", + "view_link": "Преглед на връзката", "view_links": "Преглед на връзките", "view_name": "Прегледай", "view_next_asset": "Преглед на следващия файл", @@ -1348,4 +1374,4 @@ "yes": "Да", "you_dont_have_any_shared_links": "Нямате споделени връзки", "zoom_image": "Увеличаване на изображението" -} +} \ No newline at end of file diff --git a/i18n/ca.json b/i18n/ca.json index 7d0a538f5a..77cb4a584e 100644 --- a/i18n/ca.json +++ b/i18n/ca.json @@ -20,7 +20,7 @@ "add_partner": "Afegir company/a", "add_path": "Afegir una ruta", "add_photos": "Afegir fotografies", - "add_to": "Afegir a...", + "add_to": "Afegir a…", "add_to_album": "Afegir a un l'àlbum", "add_to_shared_album": "Afegir a un àlbum compartit", "add_url": "Afegir URL", @@ -41,6 +41,7 @@ "backup_settings": "Ajustes de les còpies de seguretat", "backup_settings_description": "Gestionar la configuració de la còpia de seguretat de la base de dades", "check_all": "Marca-ho tot", + "cleanup": "Neteja", "cleared_jobs": "Tasques esborrades per a: {job}", "config_set_by_file": "La configuració està definida per un fitxer de configuració", "confirm_delete_library": "Esteu segurs que voleu eliminar la llibreria {library}?", @@ -96,7 +97,7 @@ "library_scanning_enable_description": "Habilita l'escaneig periòdic de biblioteques", "library_settings": "Llibreria externes", "library_settings_description": "Gestiona la configuració de les llibreries externes", - "library_tasks_description": "Realitza tasques de la bilbioteca", + "library_tasks_description": "Escaneja biblioteques externes per arxius nous o canviats", "library_watching_enable_description": "Consultar llibreries externes per detectar canvis en fitxers", "library_watching_settings": "Monitoratge de la llibreria (EXPERIMENTAL)", "library_watching_settings_description": "Monitorització automàtica de fitxers modificats", @@ -131,7 +132,7 @@ "machine_learning_smart_search_description": "Cerca imatges semànticament emprant incrustacions CLIP", "machine_learning_smart_search_enabled": "Activa la cerca intel·ligent", "machine_learning_smart_search_enabled_description": "Si està deshabilitat les imatges no es codificaran per la cerca intel·ligent.", - "machine_learning_url_description": "La URL del servidor d'aprenentatge automàtic. Si es proporciona més d'una URL, s'intentarà accedir a cada servidor en ordre fins que un d'ells respongui correctament.", + "machine_learning_url_description": "L'URL del servidor d'aprenentatge automàtic. Si es proporciona més d'un URL, s'intentarà accedir a cada servidor en ordre fins que un d'ells respongui correctament.", "manage_concurrency": "Gestiona la concurrència", "manage_log_settings": "Gestiona la configuració del registre", "map_dark_style": "Tema fosc", @@ -147,6 +148,8 @@ "map_settings": "Mapa", "map_settings_description": "Gestiona la configuració del mapa", "map_style_description": "URL a un tema del mapa style.json", + "memory_cleanup_job": "Neteja de memòries", + "memory_generate_job": "Generació de memòries", "metadata_extraction_job": "Extreure metadades", "metadata_extraction_job_description": "Extreu la informació de metadades de cada element, com per exemple el GPS i la resolució", "metadata_faces_import_setting": "Activar la importació de cares", @@ -219,7 +222,7 @@ "reset_settings_to_default": "Restablir configuracions per defecte", "reset_settings_to_recent_saved": "Restablir la configuració guardada més recent", "scanning_library": "Escanejant biblioteca", - "search_jobs": "Tasques de cerca...", + "search_jobs": "Cercar treballs…", "send_welcome_email": "Enviar correu electrònic de benvinguda", "server_external_domain_settings": "Domini extern", "server_external_domain_settings_description": "Domini per enllaços públics compartits, incloent http(s)://", @@ -240,7 +243,7 @@ "storage_template_hash_verification_enabled_description": "Activa la verificació de hash. No la desactiveu a menys que estigueu segurs de les implicacions", "storage_template_migration": "Migració de plantilles d'emmagatzematge", "storage_template_migration_description": "Aplica la {template} actual als elements pujats prèviament", - "storage_template_migration_info": "Els canvis de plantilla només s'aplicaran a nous elements. Per aplicar la plantilla rectroactivament a elements pujats prèviament, executeu la {job}.", + "storage_template_migration_info": "Les extensions es convertiran a minúscules. Els canvis de plantilla només s'aplicaran a nous elements. Per aplicar la plantilla rectroactivament a elements pujats prèviament, executeu la {job}.", "storage_template_migration_job": "Tasca de migració de la plantilla d'emmagatzematge", "storage_template_more_details": "Per obtenir més detalls sobre aquesta funció, consulteu la Storage Template i les seves implications", "storage_template_onboarding_description": "Quan està activada, aquesta funció organitzarà automàticament els fitxers en funció d'una plantilla definida per l'usuari. A causa de problemes d'estabilitat, la funció s'ha desactivat de manera predeterminada. Per obtenir més informació, consulteu la documentation.", @@ -299,7 +302,7 @@ "transcoding_max_b_frames": "Nombre màxim de B-frames", "transcoding_max_b_frames_description": "Els valors més alts milloren l'eficiència de la compressió, però alenteixen la codificació. És possible que no sigui compatible amb l'acceleració de maquinari en dispositius antics. 0 desactiva els B-frames, mentre que -1 estableix aquest valor automàticament.", "transcoding_max_bitrate": "Taxa de bits màxima", - "transcoding_max_bitrate_description": "Establir una taxa de bits màxima pot fer que les mides dels fitxers siguin més previsibles amb un cost menor per a la qualitat. A 720p, els valors típics són 2600k per a VP9 o HEVC, o 4500k per a H.264. Desactivat si s'estableix a 0.", + "transcoding_max_bitrate_description": "Establir una taxa de bits màxima pot fer que les mides dels fitxers siguin més previsibles amb un cost menor per a la qualitat. A 720p, els valors típics són 2600 kbit/s per a VP9 o HEVC, o 4500 kbit/s per a H.264. Desactivat si s'estableix a 0.", "transcoding_max_keyframe_interval": "Interval màxim de fotogrames clau", "transcoding_max_keyframe_interval_description": "Estableix la distància màxima entre fotogrames clau. Els valors més baixos empitjoren l'eficiència de la compressió, però milloren els temps de cerca i poden millorar la qualitat en escenes amb moviment ràpid. 0 estableix aquest valor automàticament.", "transcoding_optimal_description": "Vídeos superiors a la resolució objectiu o que no tenen un format acceptat", @@ -391,6 +394,7 @@ "allow_edits": "Permet editar", "allow_public_user_to_download": "Permet que l'usuari públic pugui descarregar", "allow_public_user_to_upload": "Permet que l'usuari públic pugui carregar", + "alt_text_qr_code": "Codi QR", "anti_clockwise": "En sentit antihorari", "api_key": "Clau API", "api_key_description": "Aquest valor només es mostrarà una vegada. Assegureu-vos de copiar-lo abans de tancar la finestra.", @@ -406,17 +410,17 @@ "are_these_the_same_person": "Són la mateixa persona?", "are_you_sure_to_do_this": "Esteu segurs que voleu fer-ho?", "asset_added_to_album": "Afegit a l'àlbum", - "asset_adding_to_album": "Afegint a l'àlbum...", + "asset_adding_to_album": "Afegint a l'àlbum…", "asset_description_updated": "La descripció del recurs s'ha actualitzat", "asset_filename_is_offline": "L'element {filename} està fora de línia", "asset_has_unassigned_faces": "L'element té cares no assignades", - "asset_hashing": "Hashing...", + "asset_hashing": "Hasheant…", "asset_offline": "Element fora de línia", "asset_offline_description": "Aquest recurs extern ja no es troba al disc. Poseu-vos en contacte amb el vostre administrador d'Immich per obtenir ajuda.", "asset_skipped": "Saltat", "asset_skipped_in_trash": "A la paperera", "asset_uploaded": "Carregat", - "asset_uploading": "S'està carregant...", + "asset_uploading": "S'està carregant…", "assets": "Elements", "assets_added_count": "{count, plural, one {Afegit un element} other {Afegits # elements}}", "assets_added_to_album_count": "{count, plural, one {Afegit un element} other {Afegits # elements}} a l'àlbum", @@ -464,7 +468,7 @@ "check_logs": "Comprovar els registres", "choose_matching_people_to_merge": "Trieu les persones que coincideixin per combinar-les", "city": "Ciutat", - "clear": "Neteja", + "clear": "Buida", "clear_all": "Neteja-ho tot", "clear_all_recent_searches": "Esborra totes les cerques recents", "clear_message": "Neteja el missatge", @@ -481,6 +485,7 @@ "comments_are_disabled": "Els comentaris estan desactivats", "confirm": "Confirmar", "confirm_admin_password": "Confirmeu la contrasenya d'administrador", + "confirm_delete_face": "Estàs segur que vols eliminar la cara de {name} de les cares reconegudes?", "confirm_delete_shared_link": "Esteu segurs que voleu eliminar aquest enllaç compartit?", "confirm_keep_this_delete_others": "Excepte aquest element, tots els altres de la pila se suprimiran. Esteu segur que voleu continuar?", "confirm_password": "Confirmació de contrasenya", @@ -533,6 +538,7 @@ "delete_album": "Esborra l'àlbum", "delete_api_key_prompt": "Esteu segurs que voleu eliminar aquesta clau API?", "delete_duplicates_confirmation": "Esteu segurs que voleu eliminar aquests duplicats permanentment?", + "delete_face": "Esborrar cara", "delete_key": "Suprimeix la clau", "delete_library": "Suprimeix la Llibreria", "delete_link": "Esborra l'enllaç", @@ -600,6 +606,7 @@ "enabled": "Activat", "end_date": "Data final", "error": "Error", + "error_delete_face": "Error esborrant cara de les cares reconegudes", "error_loading_image": "Error carregant la imatge", "error_title": "Error - Quelcom ha anat malament", "errors": { @@ -766,8 +773,10 @@ "go_to_folder": "Anar al directori", "go_to_search": "Vés a cercar", "group_albums_by": "Agrupa àlbums per...", + "group_country": "Agrupar per país", "group_no": "Cap agrupació", "group_owner": "Agrupar per propietari", + "group_places_by": "Agrupar llocs per...", "group_year": "Agrupar per any", "has_quota": "Quota", "hi_user": "Hola {name} ({email})", @@ -800,6 +809,7 @@ "include_shared_albums": "Inclou àlbums compartits", "include_shared_partner_assets": "Incloure elements dels companys", "individual_share": "Compartit individualment", + "individual_shares": "Espais individuals", "info": "Informació", "interval": { "day_at_onepm": "Cada dia a les 13h", @@ -822,6 +832,7 @@ "latest_version": "Última versió", "latitude": "Latitud", "leave": "Marxar", + "lens_model": "Model de lents", "let_others_respond": "Deixa que els altres responguin", "level": "Nivell", "library": "Bibilioteca", @@ -880,6 +891,7 @@ "month": "Mes", "more": "Més", "moved_to_trash": "S'ha mogut a la paperera", + "mute_memories": "Silenciar records", "my_albums": "Els meus àlbums", "name": "Nom", "name_or_nickname": "Nom o sobrenom", @@ -984,6 +996,7 @@ "pick_a_location": "Triar una ubicació", "place": "Lloc", "places": "Llocs", + "places_count": "{count, plural, one {{count, number} Lloc} other {{count, number} Llocs}}", "play": "Reprodueix", "play_memories": "Reproduir records", "play_motion_photo": "Reproduir Fotos en Moviment", @@ -1071,6 +1084,8 @@ "removed_from_archive": "Eliminat de l'arxiu", "removed_from_favorites": "Eliminat dels preferits", "removed_from_favorites_count": "{count, plural, other {# eliminats}} dels preferits", + "removed_memory": "Eliminat memòria", + "removed_photo_from_memory": "Eliminat foto de memòria", "removed_tagged_assets": "Etiqueta eliminada de {count, plural, one {# actiu} other {# actius}}", "rename": "Canviar nom", "repair": "Reparació", @@ -1079,6 +1094,7 @@ "repository": "Repositori", "require_password": "Requereix contrasenya", "require_user_to_change_password_on_first_login": "Requerir que l'usuari canviï la contrasenya en el primer inici de sessió", + "rescan": "Tornar a escanejar", "reset": "Restablir", "reset_password": "Restablir contrasenya", "reset_people_visibility": "Restablir la visibilitat de les persones", @@ -1094,7 +1110,7 @@ "review_duplicates": "Revisar duplicats", "role": "Rol", "role_editor": "Editor", - "role_viewer": "Visor", + "role_viewer": "Visualitzador", "save": "Desa", "saved_api_key": "Clau d'API guardada", "saved_profile": "Perfil guardat", @@ -1107,18 +1123,22 @@ "search": "Cerca", "search_albums": "Buscar àlbums", "search_by_context": "Buscar per context", + "search_by_description": "Cercar per descripció", + "search_by_description_example": "Jornada de senderisme a Sapa", "search_by_filename": "Cerca per nom de fitxer o extensió", "search_by_filename_example": "per exemple IMG_1234.JPG o PNG", "search_camera_make": "Buscar per fabricant de càmara...", "search_camera_model": "Buscar per model de càmera...", "search_city": "Buscar per ciutat...", "search_country": "Buscar per país...", + "search_for": "Cercar", "search_for_existing_person": "Busca una persona existent", "search_no_people": "Cap persona", "search_no_people_named": "Cap persona anomenada \"{name}\"", "search_options": "Opcions de cerca", "search_people": "Buscar persones", "search_places": "Buscar llocs", + "search_rating": "Buscar per qualificació...", "search_settings": "Configuració de cerca", "search_state": "Buscar per regió...", "search_tags": "Cercant etiquetes...", @@ -1165,6 +1185,7 @@ "shared_from_partner": "Fotos de {partner}", "shared_link_options": "Opcions d'enllaços compartits", "shared_links": "Enllaços compartits", + "shared_links_description": "Comparteix fotos i vídeos amb un enllaç", "shared_photos_and_videos_count": "{assetCount, plural, other {# fotos i vídeos compartits.}}", "shared_with_partner": "Compartit amb {partner}", "sharing": "Compartit", @@ -1187,6 +1208,7 @@ "show_person_options": "Mostra opcions de la persona", "show_progress_bar": "Mostra barra de progrés", "show_search_options": "Mostra opcions de cerca", + "show_shared_links": "Mostra els enllaços compartits", "show_slideshow_transition": "Mostra la transició de la presentació de diapositives", "show_supporter_badge": "Insígnia de contribuent", "show_supporter_badge_description": "Mostra una insígnia de contributor", @@ -1240,6 +1262,7 @@ "tag_created": "Etiqueta creada: {tag}", "tag_feature_description": "Exploreu fotos i vídeos agrupats per temes d'etiquetes lògiques", "tag_not_found_question": "No trobeu una etiqueta? Crear una nova etiqueta", + "tag_people": "Etiquetar personas", "tag_updated": "Etiqueta actualizada: {tag}", "tagged_assets": "{count, plural, one {#Etiquetat} other {#Etiquetats}} {count, plural, one {# actiu} other {# actius}}", "tags": "Etiquetes", @@ -1274,11 +1297,13 @@ "unfavorite": "Reverteix preferit", "unhide_person": "Mostra persona", "unknown": "Desconegut", + "unknown_country": "País Desconegut", "unknown_year": "Any desconegut", "unlimited": "Il·limitat", "unlink_motion_video": "Desvincular vídeo en moviment", "unlink_oauth": "Desvincula OAuth", "unlinked_oauth_account": "Compte Oauth desvinculat", + "unmute_memories": "Activar el so dels records", "unnamed_album": "Àlbum sense nom", "unnamed_album_delete_confirmation": "Segur que voleu esborrar aquest àlbum?", "unnamed_share": "Compartit sense nom", @@ -1332,6 +1357,7 @@ "view_all": "Veure tot", "view_all_users": "Mostra tot els usuaris", "view_in_timeline": "Mostrar a la línia de temps", + "view_link": "Veure enllaç", "view_links": "Mostra enllaços", "view_name": "Veure", "view_next_asset": "Mostra el següent element", @@ -1348,4 +1374,4 @@ "yes": "Sí", "you_dont_have_any_shared_links": "No tens cap enllaç compartit", "zoom_image": "Ampliar Imatge" -} +} \ No newline at end of file diff --git a/i18n/cs.json b/i18n/cs.json index fbfbdf3bfc..f43be9ee9f 100644 --- a/i18n/cs.json +++ b/i18n/cs.json @@ -20,7 +20,7 @@ "add_partner": "Přidat partnera", "add_path": "Přidat cestu", "add_photos": "Přidat fotky", - "add_to": "Přidat do...", + "add_to": "Přidat do…", "add_to_album": "Přidat do alba", "add_to_shared_album": "Přidat do sdíleného alba", "add_url": "Přidat URL", @@ -41,6 +41,7 @@ "backup_settings": "Nastavení zálohování", "backup_settings_description": "Správa nastavení zálohování databáze", "check_all": "Vše zkontrolovat", + "cleanup": "Vyčištění", "cleared_jobs": "Hotové úlohy pro: {job}", "config_set_by_file": "Konfigurace je aktuálně prováděna konfiguračním souborem", "confirm_delete_library": "Opravdu chcete odstranit knihovnu {library}?", @@ -96,7 +97,7 @@ "library_scanning_enable_description": "Povolit pravidelné prohledávání knihovny", "library_settings": "Externí knihovna", "library_settings_description": "Správa nastavení externí knihovny", - "library_tasks_description": "Provádění úkolů v knihovně", + "library_tasks_description": "Vyhledávání nových nebo změněných položek v externích knihovnách", "library_watching_enable_description": "Sledovat změny souborů v externích knihovnách", "library_watching_settings": "Sledování knihovny (EXPERIMENTÁLNÍ)", "library_watching_settings_description": "Automatické sledování změněných souborů", @@ -131,7 +132,7 @@ "machine_learning_smart_search_description": "Sémantické vyhledávání obrázků pomocí CLIP embeddings", "machine_learning_smart_search_enabled": "Povolit chytré vyhledávání", "machine_learning_smart_search_enabled_description": "Pokud je vypnuto, obrázky nebudou kódovány pro inteligentní vyhledávání.", - "machine_learning_url_description": "URL serveru strojového učení. Pokud je zadáno více URL adres, budou jednotlivé servery zkoušeny postupně, dokud jeden z nich neodpoví úspěšně, a to v pořadí od prvního k poslednímu.", + "machine_learning_url_description": "URL serveru strojového učení. Pokud je zadáno více URL adres, budou jednotlivé servery zkoušeny postupně, dokud jeden z nich neodpoví úspěšně, a to v pořadí od prvního k poslednímu. Servery, které neodpoví, budou dočasně ignorovány, dokud nebudou opět online.", "manage_concurrency": "Správa souběžnosti", "manage_log_settings": "Správa nastavení protokolu", "map_dark_style": "Tmavý motiv", @@ -147,6 +148,8 @@ "map_settings": "Mapa", "map_settings_description": "Správa nastavení mapy", "map_style_description": "URL na style.json motivu", + "memory_cleanup_job": "Promazání vzpomínek", + "memory_generate_job": "Vytvoření vzpomínek", "metadata_extraction_job": "Extrakce metadat", "metadata_extraction_job_description": "Získání informací o metadatech z každého snímku, jako je GPS, obličeje a rozlišení", "metadata_faces_import_setting": "Povolit import obličeje", @@ -219,7 +222,7 @@ "reset_settings_to_default": "Obnovení výchozího nastavení", "reset_settings_to_recent_saved": "Obnovit poslední uložené nastavení", "scanning_library": "Prohledat knihovnu", - "search_jobs": "Hledat úlohy...", + "search_jobs": "Hledat úlohy…", "send_welcome_email": "Odeslat uvítací e-mail", "server_external_domain_settings": "Externí doména", "server_external_domain_settings_description": "Doména pro veřejně sdílené odkazy, včetně http(s)://", @@ -240,7 +243,7 @@ "storage_template_hash_verification_enabled_description": "Povolí ověřování hashe, nevypínejte ji, pokud si nejste jisti důsledky", "storage_template_migration": "Migrace šablony úložiště", "storage_template_migration_description": "Použít aktuální {template} na dříve nahrané položky", - "storage_template_migration_info": "Změny šablon se uplatní pouze u nových položek. Chcete-li šablonu zpětně použít na dříve nahrané položky, spusťte {job}.", + "storage_template_migration_info": "Šablona úložiště převede všechny přípony na malá písmena. Změny šablon se uplatní pouze u nových položek. Chcete-li šablonu zpětně použít na dříve nahrané položky, spusťte {job}.", "storage_template_migration_job": "Úloha migrace šablony úložiště", "storage_template_more_details": "Další podrobnosti o této funkci naleznete v sekci Šablona úložiště včetně jejích důsledků", "storage_template_onboarding_description": "Je-li tato funkce povolena, automaticky uspořádá soubory na základě uživatelem definované šablony. Z důvodu problémů se stabilitou byla tato funkce ve výchozím nastavení vypnuta. Další informace naleznete v dokumentaci.", @@ -288,7 +291,7 @@ "transcoding_constant_quality_mode_description": "ICQ je lepší než CQP, ale některá zařízení pro hardwarovou akceleraci tento režim nepodporují. Nastavením této volby se při použití kódování založeného na kvalitě upřednostní zadaný režim. Ignorováno NVENC, protože nepodporuje ICQ.", "transcoding_constant_rate_factor": "Faktor konstantní rychlosti (-crf)", "transcoding_constant_rate_factor_description": "Úroveň kvality videa. Typické hodnoty jsou 23 pro H.264, 28 pro HEVC, 31 pro VP9 a 35 pro AV1. Nižší je lepší, ale vytváří větší soubory.", - "transcoding_disabled_description": "Nepřekódovávejte žádná videa, u některých klientů může dojít k znemožnění přehrávání", + "transcoding_disabled_description": "Nepřekódovávat žádná videa, u některých klientů může dojít k znemožnění přehrávání", "transcoding_encoding_options": "Možnosti kódování", "transcoding_encoding_options_description": "Nastavte kodeky, rozlišení, kvalitu a další možnosti pro kódovaná videa", "transcoding_hardware_acceleration": "Hardwarová akcelerace", @@ -299,7 +302,7 @@ "transcoding_max_b_frames": "Maximální počet B-snímků", "transcoding_max_b_frames_description": "Vyšší hodnoty zvyšují účinnost komprese, ale zpomalují kódování. Nemusí být kompatibilní s hardwarovou akcelerací na starších zařízeních. Hodnota 0 zakáže B-snímky, zatímco -1 tuto hodnotu nastaví automaticky.", "transcoding_max_bitrate": "Maximální datový tok", - "transcoding_max_bitrate_description": "Nastavení maximálního datového toku může zvýšit předvídatelnost velikosti souborů za cenu menší újmy na kvalitě. Při rozlišení 720p jsou typické hodnoty 2600k pro VP9 nebo HEVC nebo 4500k pro H.264. Je zakázáno, pokud je nastavena hodnota 0.", + "transcoding_max_bitrate_description": "Nastavení maximálního datového toku může zvýšit předvídatelnost velikosti souborů za cenu menší újmy na kvalitě. Při rozlišení 720p jsou typické hodnoty 2600 kbit/s pro VP9 nebo HEVC nebo 4500 kbit/s pro H.264. Je zakázáno, pokud je nastavena hodnota 0.", "transcoding_max_keyframe_interval": "Maximální interval klíčových snímků", "transcoding_max_keyframe_interval_description": "Nastavuje maximální vzdálenost mezi klíčovými snímky. Nižší hodnoty zhoršují účinnost komprese, ale zlepšují rychlost při přeskakování a mohou zlepšit kvalitu ve scénách s rychlým pohybem. Hodnota 0 nastavuje tuto hodnotu automaticky.", "transcoding_optimal_description": "Videa s vyšším než cílovým rozlišením nebo videa, která nejsou v akceptovaném formátu", @@ -391,6 +394,7 @@ "allow_edits": "Povolit úpravy", "allow_public_user_to_download": "Povolit veřejnosti stahovat", "allow_public_user_to_upload": "Povolit veřejnosti nahrávat", + "alt_text_qr_code": "Obrázek QR kódu", "anti_clockwise": "Proti směru hodinových ručiček", "api_key": "API klíč", "api_key_description": "Tato hodnota se zobrazí pouze jednou. Před zavřením okna ji nezapomeňte zkopírovat.", @@ -406,17 +410,17 @@ "are_these_the_same_person": "Jedná se o stejnou osobu?", "are_you_sure_to_do_this": "Opravdu to chcete udělat?", "asset_added_to_album": "Přidáno do alba", - "asset_adding_to_album": "Přidávání do alba...", + "asset_adding_to_album": "Přidávání do alba…", "asset_description_updated": "Popis položky byl aktualizován", "asset_filename_is_offline": "Položka {filename} je offline", "asset_has_unassigned_faces": "Položka má nepřiřazené obličeje", - "asset_hashing": "Hashování...", + "asset_hashing": "Hashování…", "asset_offline": "Offline položka", "asset_offline_description": "Toto externí položka se již na disku nenachází. Obraťte se na Immich správce a požádejte o pomoc.", "asset_skipped": "Přeskočeno", "asset_skipped_in_trash": "V koši", "asset_uploaded": "Nahráno", - "asset_uploading": "Nahrávání...", + "asset_uploading": "Nahrávání…", "assets": "Položky", "assets_added_count": "{count, plural, one {Přidána # položka} few {Přidány # položky} other {Přidáno # položek}}", "assets_added_to_album_count": "Do alba {count, plural, one {byla přidána # položka} few {byly přidány # položky} other {bylo přidáno # položek}}", @@ -464,11 +468,11 @@ "check_logs": "Zkontrolujte protokoly", "choose_matching_people_to_merge": "Zvolte odpovídající osoby ke sloučení", "city": "Město", - "clear": "Vyčistit", + "clear": "Vymazat", "clear_all": "Vymazat vše", "clear_all_recent_searches": "Vymazat všechna nedávná vyhledávání", - "clear_message": "Vyčistit zprávu", - "clear_value": "Vyčistit hodnotu", + "clear_message": "Vymazat zprávu", + "clear_value": "Vymazat hodnotu", "clockwise": "Po směru hodinových ručiček", "close": "Zavřít", "collapse": "Sbalit", @@ -481,6 +485,7 @@ "comments_are_disabled": "Komentáře jsou vypnuty", "confirm": "Potvrdit", "confirm_admin_password": "Potvrzení hesla správce", + "confirm_delete_face": "Opravdu chcete z položky odstranit obličej osoby {name}?", "confirm_delete_shared_link": "Opravdu chcete odstranit tento sdílený odkaz?", "confirm_keep_this_delete_others": "Všechny ostatní položky v tomto uskupení mimo této budou odstraněny. Opravdu chcete pokračovat?", "confirm_password": "Potvrzení hesla", @@ -533,6 +538,7 @@ "delete_album": "Smazat album", "delete_api_key_prompt": "Opravdu chcete tento API klíč odstranit?", "delete_duplicates_confirmation": "Opravdu chcete tyto duplicity trvale odstranit?", + "delete_face": "Odstranit obličej", "delete_key": "Smazat klíč", "delete_library": "Smazat knihovnu", "delete_link": "Smazat odkaz", @@ -600,6 +606,7 @@ "enabled": "Povoleno", "end_date": "Konečné datum", "error": "Chyba", + "error_delete_face": "Chyba při odstraňování obličeje z položky", "error_loading_image": "Chyba při načítání obrázku", "error_title": "Chyba - Něco se pokazilo", "errors": { @@ -751,8 +758,8 @@ "features_setting_description": "Správa funkcí aplikace", "file_name": "Název souboru", "file_name_or_extension": "Název nebo přípona souboru", - "filename": "Filename", - "filetype": "Filetype", + "filename": "Název souboru", + "filetype": "Typ souboru", "filter_people": "Filtrovat lidi", "find_them_fast": "Najděte je rychle vyhledáním jejich jména", "fix_incorrect_match": "Opravit nesprávnou shodu", @@ -766,8 +773,10 @@ "go_to_folder": "Přejít do složky", "go_to_search": "Přejít na vyhledávání", "group_albums_by": "Seskupit alba podle...", + "group_country": "Seskupit podle země", "group_no": "Neseskupovat", "group_owner": "Seskupit podle uživatele", + "group_places_by": "Seskupit místa podle...", "group_year": "Seskupit podle roku", "has_quota": "Má kvótu", "hi_user": "Ahoj {name} ({email})", @@ -800,6 +809,7 @@ "include_shared_albums": "Včetně sdílených alb", "include_shared_partner_assets": "Včetně sdílených položek partnera", "individual_share": "Sdílení jednotlivých položek", + "individual_shares": "Sdílení jednotlivých položek", "info": "Informace", "interval": { "day_at_onepm": "Každý den ve 13:00", @@ -822,6 +832,7 @@ "latest_version": "Nejnovější verze", "latitude": "Zeměpisná šířka", "leave": "Opustit", + "lens_model": "Model objektivu", "let_others_respond": "Nechte ostatní reagovat", "level": "Úroveň", "library": "Knihovna", @@ -880,6 +891,7 @@ "month": "Měsíc", "more": "Více", "moved_to_trash": "Přesunuto do koše", + "mute_memories": "Ztlumit vzpomínky", "my_albums": "Moje alba", "name": "Jméno", "name_or_nickname": "Jméno nebo přezdívka", @@ -975,6 +987,7 @@ "permanently_deleted_asset": "Položka trvale odstraněna", "permanently_deleted_assets_count": "{count, plural, one {Položka trvale vymazána} other {Položky trvale vymazány}}", "person": "Osoba", + "person_birthdate": "Narozen/a {date}", "person_hidden": "{name}{hidden, select, true { (skryto)} other {}}", "photo_shared_all_users": "Vypadá to, že jste fotky sdíleli se všemi uživateli, nebo nemáte žádného uživatele, se kterým byste je mohli sdílet.", "photos": "Fotky", @@ -984,6 +997,7 @@ "pick_a_location": "Vyberte polohu", "place": "Místo", "places": "Místa", + "places_count": "{count, plural, one {{count, number} místo} few {{count, number} místa} other {{count, number} míst}}", "play": "Přehrávat", "play_memories": "Přehrát vzpomníky", "play_motion_photo": "Přehrát pohybovou fotografii", @@ -1071,6 +1085,8 @@ "removed_from_archive": "Odstraněno z archivu", "removed_from_favorites": "Odstraněno z oblíbených", "removed_from_favorites_count": "{count, plural, one {Odstraněn #} few {Odstraněny #} other {Odstraněno #}} z oblíbených", + "removed_memory": "Vzpomínka odstraněna", + "removed_photo_from_memory": "Fotografie odstraněna ze vzpomínky", "removed_tagged_assets": "Odstraněná značka z {count, plural, one {# položky} other {# položek}}", "rename": "Přejmenovat", "repair": "Opravy", @@ -1079,6 +1095,7 @@ "repository": "Repozitář", "require_password": "Požadovat heslo", "require_user_to_change_password_on_first_login": "Požadovat, aby si uživatel při prvním přihlášení změnil heslo", + "rescan": "Znovu prohledat", "reset": "Výchozí", "reset_password": "Obnovit heslo", "reset_people_visibility": "Obnovit viditelnost lidí", @@ -1107,18 +1124,22 @@ "search": "Hledat", "search_albums": "Vyhledávejte alba", "search_by_context": "Vyhledávání podle obsahu", + "search_by_description": "Vyhledávat podle popisu", + "search_by_description_example": "Pěší turistika v Sapě", "search_by_filename": "Vyhledávání podle názvu nebo přípony souboru", "search_by_filename_example": "např. IMG_1234.JPG nebo PNG", "search_camera_make": "Vyhledat výrobce fotoaparátu...", "search_camera_model": "Vyhledat model fotoaparátu...", "search_city": "Vyhledat město...", "search_country": "Vyhledat zemi...", + "search_for": "Vyhledat", "search_for_existing_person": "Vyhledat existující osobu", "search_no_people": "Žádní lidé", "search_no_people_named": "Žádní lidé se jménem \"{name}\"", "search_options": "Možnosti vyhledávání", "search_people": "Vyhledat lidi", "search_places": "Vyhledat místa", + "search_rating": "Vyhledávání podle hodnocení...", "search_settings": "Hledat nastavení", "search_state": "Vyhledat stát...", "search_tags": "Vyhledávat značky...", @@ -1165,6 +1186,7 @@ "shared_from_partner": "Fotky od {partner}", "shared_link_options": "Možnosti sdíleného odkazu", "shared_links": "Sdílené odkazy", + "shared_links_description": "Sdílet fotky a videa pomocí odkazu", "shared_photos_and_videos_count": "{assetCount, plural, one {# sdílená fotografie a video.} few {# sdílené fotografie a videa.} other {# sdílených fotografií a videí.}}", "shared_with_partner": "Sdíleno s {partner}", "sharing": "Sdílení", @@ -1187,6 +1209,7 @@ "show_person_options": "Zobrazit možnosti osoby", "show_progress_bar": "Zobrazit ukazatel průběhu", "show_search_options": "Zobrazit možnosti vyhledávání", + "show_shared_links": "Zobrazit sdílené odkazy", "show_slideshow_transition": "Zobrazit přechod prezentace", "show_supporter_badge": "Odznak podporovatele", "show_supporter_badge_description": "Zobrazit odznak podporovatele", @@ -1240,6 +1263,7 @@ "tag_created": "Vytvořena značka: {tag}", "tag_feature_description": "Procházení fotografií a videí seskupených podle témat logických značek", "tag_not_found_question": "Nemůžete najít značku? Vytvořte novou.", + "tag_people": "Označit lidi", "tag_updated": "Aktualizována značka: {tag}", "tagged_assets": "Přiřazena značka {count, plural, one {# položce} other {# položkám}}", "tags": "Značky", @@ -1274,11 +1298,13 @@ "unfavorite": "Zrušit oblíbení", "unhide_person": "Zrušit skrytí osoby", "unknown": "Neznámý", + "unknown_country": "Neznámá země", "unknown_year": "Neznámý rok", "unlimited": "Neomezeně", "unlink_motion_video": "Odpojit pohyblivé video", "unlink_oauth": "Zrušit OAuth propojení", "unlinked_oauth_account": "OAuth účet odpojen", + "unmute_memories": "Zrušit ztlumení vzpomínek", "unnamed_album": "Nepojmenované album", "unnamed_album_delete_confirmation": "Opravdu chcete toto album smazat?", "unnamed_share": "Nepojmenované sdílení", @@ -1332,6 +1358,7 @@ "view_all": "Zobrazit vše", "view_all_users": "Zobrazit všechny uživatele", "view_in_timeline": "Zobrazit na časové ose", + "view_link": "Zobrazit odkaz", "view_links": "Zobrazit odkazy", "view_name": "Zobrazit", "view_next_asset": "Zobrazit další položku", @@ -1344,8 +1371,8 @@ "welcome": "Vítejte", "welcome_to_immich": "Vítejte v Immichi", "year": "Rok", - "years_ago": "Před {years, plural, one {# rokem} other {# lety}}", + "years_ago": "Před {years, plural, one {rokem} other {# lety}}", "yes": "Ano", "you_dont_have_any_shared_links": "Nemáte žádné sdílené odkazy", "zoom_image": "Zvětšit obrázek" -} +} \ No newline at end of file diff --git a/i18n/cv.json b/i18n/cv.json index 19a7a86ae4..9010911c25 100644 --- a/i18n/cv.json +++ b/i18n/cv.json @@ -20,7 +20,7 @@ "add_partner": "Мӑшӑр хуш", "add_path": "Ҫулне хуш", "add_photos": "Сӑнӳкерчӗксем хуш", - "add_to": "Мӗн те пулин хуш...", + "add_to": "Мӗн те пулин хуш…", "add_to_album": "Альбома хуш", "add_to_shared_album": "Пӗрлехи альбома хуш", "add_url": "URL хушӑр", diff --git a/i18n/da.json b/i18n/da.json index b1fd80ff0a..0e7ce712e8 100644 --- a/i18n/da.json +++ b/i18n/da.json @@ -7,7 +7,7 @@ "actions": "Handlinger", "active": "Aktive", "activity": "Aktivitet", - "activity_changed": "Aktivitet er {aktiveret, vælg, sandt {aktiveret} andet {deaktiveret}}", + "activity_changed": "Aktivitet er {enabled, select, true {aktiveret} other {deaktiveret}}", "add": "Tilføj", "add_a_description": "Tilføj en beskrivelse", "add_a_location": "Tilføj en placering", @@ -20,7 +20,7 @@ "add_partner": "Tilføj partner", "add_path": "Tilføj sti", "add_photos": "Tilføj billeder", - "add_to": "Tilføj til...", + "add_to": "Tilføj til…", "add_to_album": "Tilføj til album", "add_to_shared_album": "Tilføj til delt album", "add_url": "Tilføj URL", @@ -147,6 +147,8 @@ "map_settings": "Kort", "map_settings_description": "Administrer kortindstillinger", "map_style_description": "URL til en style.json for et korttema", + "memory_cleanup_job": "Mindeoprydning", + "memory_generate_job": "Mindegeneration", "metadata_extraction_job": "Udtræk metadata", "metadata_extraction_job_description": "Udtræk metadataoplysninger fra hvert Billede/Video, såsom GPS og opløsning", "metadata_faces_import_setting": "Aktivér for at importere ansigter", @@ -219,7 +221,7 @@ "reset_settings_to_default": "Nulstil indstillingerne til standard", "reset_settings_to_recent_saved": "Nulstil indstillinger til de senest gemte indstillinger", "scanning_library": "Scanner bibliotek", - "search_jobs": "søg opgaver ..", + "search_jobs": "Søg opgaver…", "send_welcome_email": "Send velkomst-email", "server_external_domain_settings": "Eksternt domæne", "server_external_domain_settings_description": "Domæne til offentligt delte links, inklusiv http(s)://", @@ -299,7 +301,7 @@ "transcoding_max_b_frames": "Maksimum B-frames", "transcoding_max_b_frames_description": "Højere værdier forbedrer kompressionseffektivitet, men kan gøre indkodning langsommere. Er måske ikke kompatibelt med hardware-acceleration på ældre enheder. 0 slår B-frames fra, mens -1 sætter denne værdi automatisk.", "transcoding_max_bitrate": "Maksimal bitrate", - "transcoding_max_bitrate_description": "At sætte en maksmimal bitrate kan gøre filstørrelserne mere forudsigelige med et lille tab i kvalitet. Ved 720p er almindelige værdier 2600k for VP9 eller HEVC, eller 4500K for H.264. Slået fra hvis sat til 0.", + "transcoding_max_bitrate_description": "At sætte en maksmimal bitrate kan gøre filstørrelserne mere forudsigelige med et lille tab i kvalitet. Ved 720p er almindelige værdier 2600 kbit/s for VP9 eller HEVC, eller 4500 kbit/s for H.264. Slået fra hvis sat til 0.", "transcoding_max_keyframe_interval": "Maksimal keyframe-interval", "transcoding_max_keyframe_interval_description": "Sætter den maksimale frameafstand mellem keyframes. Lavere værdier forringer kompressionseffektiviteten, men forbedrer søgetider og kan forbedre kvaliteten i scener med hurtig bevægelse. 0 sætter denne værdi automatisk.", "transcoding_optimal_description": "Videoer højere end målopløsningen eller ikke i et godkendt format", @@ -360,9 +362,9 @@ "admin_password": "Administratoradgangskode", "administration": "Administration", "advanced": "Avanceret", - "age_months": "Alder {months, plural, one {# month} other {# months}}", - "age_year_months": "Alder 1 år, {måneder, flertal, en {# måned} flere {# months}}", - "age_years": "{år, år, andre {Alder #}}", + "age_months": "Alder {months, plural, one {# måned} other {# måneder}}", + "age_year_months": "Alder 1 år, {months, plural, one {# måned} other {# måneder}}", + "age_years": "{years, plural, other {Alder #}}", "album_added": "Album tilføjet", "album_added_notification_setting_description": "Modtag en emailnotifikation når du bliver tilføjet til en delt album", "album_cover_updated": "Albumcover opdateret", @@ -402,33 +404,33 @@ "archive_or_unarchive_photo": "Arkivér eller dearkivér billede", "archive_size": "Arkiv størelse", "archive_size_description": "Konfigurer arkivstørrelsen for downloads (i GiB)", - "archived_count": "{antal, flertal, andet {Arkiveret #}}", + "archived_count": "{count, plural, other {Arkiveret #}}", "are_these_the_same_person": "Er disse den samme person?", "are_you_sure_to_do_this": "Er du sikker på, at du vil gøre det her?", "asset_added_to_album": "Tilføjet til album", - "asset_adding_to_album": "Tilføjer til album...", + "asset_adding_to_album": "Tilføjer til album…", "asset_description_updated": "Mediefilsbeskrivelse er blevet opdateret", "asset_filename_is_offline": "Mediefil {filename} er offline", "asset_has_unassigned_faces": "Aktivet har ikke-tildelte ansigter", - "asset_hashing": "Hashing...", + "asset_hashing": "Hashing…", "asset_offline": "Mediefil offline", "asset_offline_description": "Denne eksterne mediefil kan ikke længere findes på drevet. Kontakt venligst din Immich-administrator for hjælp.", "asset_skipped": "Sprunget over", "asset_skipped_in_trash": "I skraldespand", - "asset_uploaded": "Uploaded", - "asset_uploading": "Uploader...", + "asset_uploaded": "Uploadet", + "asset_uploading": "Uploader…", "assets": "elementer", "assets_added_count": "Tilføjet {count, plural, one {# mediefil} other {# mediefiler}}", - "assets_added_to_album_count": "Tilføjet {count, plural, one {# mediefil} other {# mediefiler}} til albummet", + "assets_added_to_album_count": "{count, plural, one {# mediefil} other {# mediefiler}} tilføjet til albummet", "assets_added_to_name_count": "Tilføjet {count, plural, one {# mediefil} other {# mediefiler}} til {hasName, select, true {{name}} other {nyt album}}", "assets_count": "{count, plural, one {# mediefil} other {# mediefiler}}", "assets_moved_to_trash_count": "Flyttede {count, plural, one {# mediefil} other {# mediefiler}} til papirkurven", - "assets_permanently_deleted_count": "Slettet permanent {count, plural, one {# mediefil} other {# mediefiler}}", + "assets_permanently_deleted_count": "{count, plural, one {# mediefil} other {# mediefiler}} slettet permanent", "assets_removed_count": "Fjernede {count, plural, one {# mediefil} other {# mediefiler}}", - "assets_restore_confirmation": "Er du sikker på, at du vil gendanne alle dine aktiver i papirkurven? Du kan ikke fortryde denne handling! Bemærk, at offline mediefiler ikke kan gendannes på denne måde.", - "assets_restored_count": "Gendannet {count, plural, one {# mediefil} other {# mediefiler}}", - "assets_trashed_count": "Smidt {count, plural, one {# mediefil} other {# mediefiler}} i papirkurven", - "assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} er allerede en del af albummet", + "assets_restore_confirmation": "Er du sikker på, at du vil gendanne alle dine mediafiler i papirkurven? Du kan ikke fortryde denne handling! Bemærk, at offline mediefiler ikke kan gendannes på denne måde.", + "assets_restored_count": "{count, plural, one {# mediefil} other {# mediefiler}} gendannet", + "assets_trashed_count": "{count, plural, one {# mediefil} other {# mediefiler}} smidt i papirkurven", + "assets_were_part_of_album_count": "mediefil{count, plural, one {mediefil} other {mediefiler}} er allerede en del af albummet", "authorized_devices": "Tilladte enheder", "back": "Tilbage", "back_close_deselect": "Tilbage, luk eller fravælg", @@ -441,7 +443,7 @@ "build_image": "Byggefil", "bulk_delete_duplicates_confirmation": "Er du sikker på, at du vil slette alle {count, plural, one {# duplicate asset} other {# duplicate assets}}? Dette vil beholde den største fil i hver gruppe og slette alle dubletter. Denne handling kan ikke fortrydes!", "bulk_keep_duplicates_confirmation": "Er du sikker på, at du vil beholde {count, plural, one {# duplicate asset} other {# duplicate assets}}? Dette vil løse alle dubletgrupper uden at slette noget.", - "bulk_trash_duplicates_confirmation": "Er du sikker på, at du vil masseslette {count, plural, one {# duplicate asset} other {# duplicate assets}}? Dette vil beholde det største aktiv i hver gruppe og smide alle andre dubletter.", + "bulk_trash_duplicates_confirmation": "Er du sikker på, at du vil masseslette {count, plural, one {# duplikeret objekt} other {# duplikerede objekter}}? Dette vil beholde det største objekt i hver gruppe og slette alle andre dubletter.", "buy": "Køb Immich", "camera": "Kamera", "camera_brand": "Kameramærke", @@ -481,6 +483,7 @@ "comments_are_disabled": "Kommentarer er slået fra", "confirm": "Bekræft", "confirm_admin_password": "Bekræft administratoradgangskode", + "confirm_delete_face": "Er du sikker på, du vil slette {name}s ansigt fra denne mediefil?", "confirm_delete_shared_link": "Er du sikker på, at du vil slette dette delte link?", "confirm_keep_this_delete_others": "Alle andre aktiver i stakken vil blive slettet undtagen dette aktiv. Er du sikker på, at du vil fortsætte?", "confirm_password": "Bekræft adgangskode", @@ -504,7 +507,7 @@ "create_library": "Opret bibliotek", "create_link": "Opret link", "create_link_to_share": "Opret link for at dele", - "create_link_to_share_description": "Lad alle med linket se de(t) valgte billede(r)", + "create_link_to_share_description": "Tillad alle med linket at se de(t) valgte billede(r)", "create_new_person": "Opret ny person", "create_new_person_hint": "Tildel valgte aktiver til en ny person", "create_new_user": "Opret ny bruger", @@ -519,20 +522,21 @@ "date_after": "Dato efter", "date_and_time": "Dato og klokkeslæt", "date_before": "Dato før", - "date_of_birth_saved": "Fødselsdatoen blev gemt", + "date_of_birth_saved": "Fødselsdatoen blev gemt korrekt", "date_range": "Datointerval", "day": "Dag", - "deduplicate_all": "Dedupliker alle", + "deduplicate_all": "Kopier alle", "deduplication_criteria_1": "Billedstørrelse i bytes", "deduplication_criteria_2": "Antal EXIF-data", "deduplication_info": "Deduplikerings info", "deduplication_info_description": "For automatisk at forudvælge emner og fjerne dubletter i bulk ser vi på:", "default_locale": "Standardlokalitet", - "default_locale_description": "Formatér datoer og tal", + "default_locale_description": "Formatér datoer og tal baseret på din browsers regions indstillinger", "delete": "Slet", "delete_album": "Slet album", "delete_api_key_prompt": "Er du sikker på, at du vil slette denne API-nøgle?", "delete_duplicates_confirmation": "Er du sikker på, at du vil slette disse dubletter permanent?", + "delete_face": "Slet ansigt", "delete_key": "Slet nøgle", "delete_library": "Slet bibliotek", "delete_link": "Slet link", @@ -565,7 +569,7 @@ "download_settings": "Download", "download_settings_description": "Administrer indstillinger relateret til mediefil-downloads", "downloading": "Downloader", - "downloading_asset_filename": "Downloader aktiv {filename}", + "downloading_asset_filename": "Downloader mediefil {filename}", "drop_files_to_upload": "Slip filer hvor som helst for at uploade dem", "duplicates": "Duplikater", "duplicates_description": "Løs hver gruppe ved at angive, hvilke, hvis nogen, er dubletter", @@ -595,11 +599,12 @@ "editor_crop_tool_h2_rotation": "Rotation", "email": "E-mail", "empty_trash": "Tøm papirkurv", - "empty_trash_confirmation": "Er du sikker på, at du vil tømme papirkurven? Dette vil fjerne alle aktiver i papirkurven permanent fra Immich.\nDu kan ikke fortryde denne handling!", + "empty_trash_confirmation": "Er du sikker på, at du vil tømme papirkurven? Dette vil fjerne alle objekter i papirkurven permanent fra Immich.\nDu kan ikke fortryde denne handling!", "enable": "Aktivér", "enabled": "Aktiveret", "end_date": "Slutdato", "error": "Fejl", + "error_delete_face": "Fejl ved sletning af ansigt fra mediefil", "error_loading_image": "Fejl ved indlæsning af billede", "error_title": "Fejl - Noget gik galt", "errors": { @@ -607,11 +612,11 @@ "cannot_navigate_previous_asset": "Kan ikke navigere til forrige mediefil", "cant_apply_changes": "Ændringerne kan ikke anvendes", "cant_change_activity": "Kan ikke {enabled, select, true {disable} other {enable}} aktivitet", - "cant_change_asset_favorite": "Kan ikke ændre favorit til aktiv", - "cant_change_metadata_assets_count": "Kan ikke ændre metadata for {count, plural, one {# asset} other {# assets}}", + "cant_change_asset_favorite": "Kan ikke ændre favorit til mediefil", + "cant_change_metadata_assets_count": "Kan ikke ændre metadata for {count, plural, one {# objekt} other {# objekter}}", "cant_get_faces": "Kan ikke hente ansigter", "cant_get_number_of_comments": "Kan ikke få antallet af kommentarer", - "cant_search_people": "Kan ikke søge efter folk", + "cant_search_people": "Kan ikke søge efter personer", "cant_search_places": "Kan ikke søge efter steder", "cleared_jobs": "Ryddede opgaver for: {job}", "error_adding_assets_to_album": "Fejl i tilføjelse af mediefiler til album", @@ -620,20 +625,20 @@ "error_downloading": "Fejl i download af {filename}", "error_hiding_buy_button": "Fejl i skjulning af køb-knap", "error_removing_assets_from_album": "Fejl i fjernelse af mediefiler fra album. Tjek konsol for flere detaljer", - "error_selecting_all_assets": "Fejl ved valg af alle aktiver", + "error_selecting_all_assets": "Fejl ved valg af alle mediefiler", "exclusion_pattern_already_exists": "Denne udelukkelsesmønster findes allerede.", "failed_job_command": "Kommando {command} slog fejl for opgave: {job}", "failed_to_create_album": "Oprettelse af album mislykkedes", "failed_to_create_shared_link": "Oprettelse af delt link mislykkedes", "failed_to_edit_shared_link": "Redigering af delt link mislykkedes", - "failed_to_get_people": "Det lykkedes ikke at hente folk", - "failed_to_keep_this_delete_others": "Kunne ikke beholde dette aktiv og slette de andre aktiver", + "failed_to_get_people": "Det lykkedes ikke at hente personer", + "failed_to_keep_this_delete_others": "Kunne ikke beholde denne mediefil og slette de andre mediefiler", "failed_to_load_asset": "Indlæsning af mediefil mislykkedes", "failed_to_load_assets": "Indlæsning af mediefiler mislykkedes", "failed_to_load_people": "Indlæsning af personer mislykkedes", "failed_to_remove_product_key": "Fjernelse af produktnøgle mislykkedes", - "failed_to_stack_assets": "Det lykkedes ikke at stable aktiver", - "failed_to_unstack_assets": "Det lykkedes ikke at fjerne stablen af aktiver", + "failed_to_stack_assets": "Det lykkedes ikke at stable mediefiler", + "failed_to_unstack_assets": "Det lykkedes ikke at fjerne gruperingen af mediefiler", "import_path_already_exists": "Denne importsti findes allerede.", "incorrect_email_or_password": "Forkert email eller kodeord", "paths_validation_failed": "{paths, plural, one {# sti} other {# stier}} slog fejl ved validering", @@ -641,17 +646,17 @@ "quota_higher_than_disk_size": "Du har sat en kvote der er større end disken", "repair_unable_to_check_items": "Kunne ikke tjekke {count, select, one {element} other {elementer}}", "unable_to_add_album_users": "Ikke i stand til at tilføje brugere til album", - "unable_to_add_assets_to_shared_link": "Kan ikke tilføje aktiver til delt link", + "unable_to_add_assets_to_shared_link": "Kan ikke tilføje mediefiler til det delte link", "unable_to_add_comment": "Ikke i stand til at tilføje kommentar", "unable_to_add_exclusion_pattern": "Kunne ikke tilføje udelukkelsesmønster", "unable_to_add_import_path": "Kunne ikke tilføje importsti", "unable_to_add_partners": "Ikke i stand til at tilføje partnere", "unable_to_add_remove_archive": "Kan Ikke {archived, select, true {fjerne aktiv fra} other {tilføje aktiv til}} Arkiv", "unable_to_add_remove_favorites": "Kan ikke {favorite, select, true {tilføje aktiv til} other {fjerne aktiv fra}} favoritter", - "unable_to_archive_unarchive": "Ude af stand til at {arkiveret, vælg, sand {arkiv} andet {arkiv}}", + "unable_to_archive_unarchive": "Ude af stand til at {archived, select, true {arkivere} other {fjerne fra arkiv}}", "unable_to_change_album_user_role": "Ikke i stand til at ændre albumbrugerens rolle", "unable_to_change_date": "Ikke i stand til at ændre dato", - "unable_to_change_favorite": "Kan ikke ændre favorit for aktiv", + "unable_to_change_favorite": "Kan ikke ændre favorit for mediefil", "unable_to_change_location": "Ikke i stand til at ændre sted", "unable_to_change_password": "Kunne ikke ændre adgangskode", "unable_to_change_visibility": "Kan ikke ændre synligheden for {count, plural, one {# person} other {# personer}}", @@ -689,8 +694,8 @@ "unable_to_log_out_device": "Enheden kunne ikke logges af", "unable_to_login_with_oauth": "Kan ikke logge på med OAuth", "unable_to_play_video": "Ikke i stand til at afspille video", - "unable_to_reassign_assets_existing_person": "Kan ikke gentildele aktiver til {navn, vælg, null {en eksisterende person} anden {{name}}}", - "unable_to_reassign_assets_new_person": "Kan ikke omfordele aktiver til en ny person", + "unable_to_reassign_assets_existing_person": "Kunne ikke tildele mediafiler til {name, select, null {en eksisterende person} other {{name}}}", + "unable_to_reassign_assets_new_person": "Kan ikke omfordele objekter til en ny person", "unable_to_refresh_user": "Ikke i stand til at genopfriske bruger", "unable_to_remove_album_users": "Ikke i stand til at fjerne brugere fra album", "unable_to_remove_api_key": "Kunne ikke fjerne API-nøgle", @@ -698,16 +703,16 @@ "unable_to_remove_deleted_assets": "Kunne ikke fjerne offlinefiler", "unable_to_remove_library": "Ikke i stand til at fjerne bibliotek", "unable_to_remove_partner": "Ikke i stand til at fjerne partner", - "unable_to_remove_reaction": "Ikke i stand til at reaktion", + "unable_to_remove_reaction": "Ikke i stand til at fjerne reaktion", "unable_to_repair_items": "Ikke i stand til at reparere ting", "unable_to_reset_password": "Ikke i stand til at nulstille adgangskode", "unable_to_resolve_duplicate": "Kunne ikke opklare duplikat", - "unable_to_restore_assets": "Kunne ikke genoprette medier", - "unable_to_restore_trash": "Ikke i stand til at genoprette skrald", - "unable_to_restore_user": "Ikke i stand til at genoprette bruger", + "unable_to_restore_assets": "Kunne ikke gendanne medierfil", + "unable_to_restore_trash": "Ikke i stand til at gendanne fra skraldespanden", + "unable_to_restore_user": "Ikke i stand til at gendanne bruger", "unable_to_save_album": "Ikke i stand til at gemme album", "unable_to_save_api_key": "Kunne ikke gemme API-nøgle", - "unable_to_save_date_of_birth": "Kan ikke gemme fødselsdatoen", + "unable_to_save_date_of_birth": "Kunne ikke gemme fødselsdatoen", "unable_to_save_name": "Ikke i stand til at gemme navn", "unable_to_save_profile": "Ikke i stand til at gemme profil", "unable_to_save_settings": "Ikke i stand til at gemme indstillinger", @@ -720,7 +725,7 @@ "unable_to_unlink_account": "Ikke i stand til at frakoble konto", "unable_to_unlink_motion_video": "Kunne ikke fjerne linket til bevægelsesvideo", "unable_to_update_album_cover": "Albumomslaget kunne ikke opdateres", - "unable_to_update_album_info": "Albumoplysningerne kunne ikke opdateres", + "unable_to_update_album_info": "Albumsoplysningerne kunne ikke opdateres", "unable_to_update_library": "Ikke i stand til at opdatere bibliotek", "unable_to_update_location": "Ikke i stand til at opdatere sted", "unable_to_update_settings": "Ikke i stand til at opdatere indstillinger", @@ -729,7 +734,7 @@ "unable_to_upload_file": "Filen kunne ikke uploades" }, "exif": "Exif", - "exit_slideshow": "Forlad slideshow", + "exit_slideshow": "Afslut slideshow", "expand_all": "Udvid alle", "expire_after": "Udløb efter", "expired": "Udløbet", @@ -742,7 +747,7 @@ "external": "Ekstern", "external_libraries": "Eksterne biblioteker", "face_unassigned": "Ikke tildelt", - "failed_to_load_assets": "Kunne ikke indlæse aktiver", + "failed_to_load_assets": "Kunne ikke indlæse mediefiler", "favorite": "Favorit", "favorite_or_unfavorite_photo": "Tilføj eller fjern fra yndlingsbilleder", "favorites": "Favoritter", @@ -766,16 +771,18 @@ "go_to_folder": "Gå til mappe", "go_to_search": "Gå til søgning", "group_albums_by": "Gruppér albummer efter...", + "group_country": "Gruppér efter land", "group_no": "Ingen gruppering", "group_owner": "Grupper efter ejer", + "group_places_by": "Gruppér steder efter...", "group_year": "Grupper efter år", "has_quota": "Har kvote", "hi_user": "Hej {name} ({email})", "hide_all_people": "Skjul alle personer", - "hide_gallery": "Gem galleri", + "hide_gallery": "Skjul galleri", "hide_named_person": "Skjul person {name}", - "hide_password": "Gem adgangskode", - "hide_person": "Gem person", + "hide_password": "Skjul adgangskode", + "hide_person": "Skjul person", "hide_unnamed_people": "Skjul unavngivne personer", "host": "Host", "hour": "Time", @@ -800,6 +807,7 @@ "include_shared_albums": "Inkludér delte albummer", "include_shared_partner_assets": "Inkludér delte partnermedier", "individual_share": "Individuel andel", + "individual_shares": "Individuelle delinger", "info": "Info", "interval": { "day_at_onepm": "Hver dag kl. 13", @@ -809,12 +817,12 @@ }, "invite_people": "Inviter personer", "invite_to_album": "Inviter til album", - "items_count": "{count, plural, one {# genstand} other {# genstande}}", + "items_count": "{count, plural, one {# element} other {# elementer}}", "jobs": "Opgaver", "keep": "Behold", "keep_all": "Behold alle", "keep_this_delete_others": "Behold dette, slet andre", - "kept_this_deleted_others": "Beholdt dette aktiv og slettede {count, plural, one {# aktiv} other {# aktiver}}", + "kept_this_deleted_others": "Beholdt denne mediefil og slettede {count, plural, one {# aktiv} other {# aktiver}}", "keyboard_shortcuts": "Tastaturgenveje", "language": "Sprog", "language_setting_description": "Vælg dit foretrukne sprog", @@ -822,19 +830,20 @@ "latest_version": "Seneste version", "latitude": "Breddegrad", "leave": "Forlad", + "lens_model": "Objektivmodel", "let_others_respond": "Lad andre svare", "level": "Niveau", "library": "Bibliotek", "library_options": "Biblioteksindstillinger", "light": "Lys", "like_deleted": "Ligesom slettet", - "link_motion_video": "Link bevægelses video", + "link_motion_video": "Link bevægelsesvideo", "link_options": "Link-indstillinger", "link_to_oauth": "Link til OAuth", "linked_oauth_account": "Tilsluttet OAuth-konto", "list": "Liste", - "loading": "Loader", - "loading_search_results_failed": "At loade søgeresultater slog fejl", + "loading": "Indlæser", + "loading_search_results_failed": "Indlæsning af søgeresultater fejlede", "log_out": "Log ud", "log_out_all_devices": "Log ud af alle enheder", "logged_out_all_devices": "Logget ud af alle enheder", @@ -864,15 +873,15 @@ "media_type": "Medietype", "memories": "Minder", "memories_setting_description": "Administrér hvad du ser i dine minder", - "memory": "Hukommelse", + "memory": "Minde", "memory_lane_title": "Minder {title}", "menu": "Menu", "merge": "Sammenflet", "merge_people": "Sammenflet personer", "merge_people_limit": "Du kan kun flette op til 5 ansigter ad gangen", - "merge_people_prompt": "Vil du slå disse mennesker sammen? Denne handling er uigenkaldelig.", + "merge_people_prompt": "Vil du flette disse mennesker sammen? Denne handling er uigenkaldelig.", "merge_people_successfully": "Personer sammenflettet med succes", - "merged_people_count": "Slået sammen {count, plural, one {# person} other {# people}}", + "merged_people_count": "{count, plural, one {# person} other {# personer}} lagt sammen", "minimize": "Minimér", "minute": "Minut", "missing": "Mangler", @@ -897,7 +906,7 @@ "no_albums_message": "Opret et album for at organisere dine billeder og videoer", "no_albums_with_name_yet": "Det ser ud til, at du ikke har noget album med dette navn endnu.", "no_albums_yet": "Det ser ud til, at du ikke har nogen album endnu.", - "no_archived_assets_message": "Arkivér billeder og fotos for at gemme dem væk fra dit Billed-view", + "no_archived_assets_message": "Arkivér billeder og videoer for at gemme dem væk fra din Billede oversigt", "no_assets_message": "KLIK FOR AT UPLOADE DIT FØRSTE BILLEDE", "no_duplicates_found": "Ingen duplikater fundet.", "no_exif_info_available": "Ingen tilgængelig exif information", @@ -923,9 +932,9 @@ "offline_paths_description": "Disse resultater kan være på grund af manuel sletning af filer, som ikke er en del af et eksternt bibliotek.", "ok": "Ok", "oldest_first": "Ældste først", - "onboarding": "Onboarding", + "onboarding": "Introduktion", "onboarding_privacy_description": "Følgende (valgfrie) funktioner er afhængige af eksterne tjenester, og kan til enhver tid deaktiveres i administrationsindstillingerne.", - "onboarding_theme_description": "Vælg et farvetema til din forekomst. Du kan ændre dette senere i dine indstillinger.", + "onboarding_theme_description": "Vælg et farvetema til din instans. Du kan ændre dette senere i dine indstillinger.", "onboarding_welcome_description": "Lad os få din instans sat op med nogle almindelige indstillinger.", "onboarding_welcome_user": "Velkommen, {user}", "online": "Online", @@ -940,11 +949,11 @@ "other": "Andet", "other_devices": "Andre enheder", "other_variables": "Andre variable", - "owned": "Ejet", + "owned": "Egne", "owner": "Ejer", "partner": "Partner", "partner_can_access": "{partner} kan tilgå", - "partner_can_access_assets": "Alle dine billeder og videoer, bortset fra dem i Arkiveret og Slettet", + "partner_can_access_assets": "Alle dine billeder og videoer, bortset fra dem i Arkivet og Slettet", "partner_can_access_location": "Stedet, hvor dine billeder blev taget", "partner_sharing": "Partnerdeling", "partners": "Partnere", @@ -973,7 +982,7 @@ "permanently_delete_assets_count": "Slet permanent {count, plural, one {asset} other {assets}}", "permanently_delete_assets_prompt": "Er du sikker på, at du permanent vil slette {count, plural, one {dette aktiv?} other {disse # aktiver?}} Dette vil også fjerne {count, plural, one {det fra dets} other {dem fra deres}} album(er).", "permanently_deleted_asset": "Permanent slettet medie", - "permanently_deleted_assets_count": "Slettet permanent {count, plural, one {# aktiv} other {# aktiver}}", + "permanently_deleted_assets_count": "{count, plural, one {# aktiv} other {# aktiver}} permanent slettet", "person": "Person", "person_hidden": "{name}{hidden, select, true { (skjult)} other {}}", "photo_shared_all_users": "Det ser ud til, at du har delt dine billeder med alle brugere, eller også har du ikke nogen bruger at dele med.", @@ -984,10 +993,11 @@ "pick_a_location": "Vælg et sted", "place": "Sted", "places": "Steder", + "places_count": "{count, plural, one {{count, number} Sted} other {{count, number} Steder}}", "play": "Afspil", "play_memories": "Afspil minder", "play_motion_photo": "Afspil bevægelsesbillede", - "play_or_pause_video": "Afspil eller paus video", + "play_or_pause_video": "Afspil eller pause video", "port": "Port", "preset": "Forudindstilling", "preview": "Forhåndsvisning", @@ -1018,11 +1028,11 @@ "purchase_input_suggestion": "Har du en produktnøgle? Indtast nøglen nedenfor", "purchase_license_subtitle": "Køb Immich for at understøtte den fortsatte udvikling af tjenesten", "purchase_lifetime_description": "Livsvarigt køb", - "purchase_option_title": "KØBEMULIGHEDER", + "purchase_option_title": "KØBSMULIGHEDER", "purchase_panel_info_1": "At bygge Immich tager meget tid og kræfter, og vi har fuldtidsingeniører, der arbejder på det for at gøre det så godt, som vi overhovedet kan. Vores mission er, at open source-software og etisk forretningspraksis bliver en bæredygtig indtægtskilde for udviklere og at skabe et privatlivsrespekterende økosystem med reelle alternativer til udnyttende cloud-tjenester.", "purchase_panel_info_2": "Da vi er forpligtet til ikke at tilføje betalingsvægge, vil dette køb ikke give dig yderligere funktioner i Immich. Vi er afhængige af, at brugere som dig støtter Immichs løbende udvikling.", "purchase_panel_title": "Støt projektet", - "purchase_per_server": "Per server", + "purchase_per_server": "Pr. server", "purchase_per_user": "Per bruger", "purchase_remove_product_key": "Fjern produktnøgle", "purchase_remove_product_key_prompt": "Er du sikker på, at du vil fjerne produktnøglen?", @@ -1039,9 +1049,9 @@ "reaction_options": "Reaktionsindstillinger", "read_changelog": "Læs ændringslog", "reassign": "Gentildel", - "reassigned_assets_to_existing_person": "Gentildelt {count, plural, one {# aktiv} other {# aktiver}} til {name, select, null {en eksisterende person} other {{navne}}}", + "reassigned_assets_to_existing_person": "{count, plural, one {# mediefil} other {# mediefiler}} er blevet gentildelt til {name, select, null {en eksisterende person} other {{name}}}", "reassigned_assets_to_new_person": "Gentildelt {count, plural, one {# aktiv} other {# aktiver}} til en ny person", - "reassing_hint": "Tildel valgte aktiver til en eksisterende person", + "reassing_hint": "Tildel valgte mediefiler til en eksisterende person", "recent": "For nylig", "recent-albums": "Seneste albums", "recent_searches": "Seneste søgninger", @@ -1059,9 +1069,9 @@ "remove": "Fjern", "remove_assets_album_confirmation": "Er du sikker på, at du vil fjerne {count, plural, one {# aktiv} other {# aktiver}} fra albummet?", "remove_assets_shared_link_confirmation": "Er du sikker på, at du vil fjerne {count, plural, one {# aktiv} other {# aktiver}} fra dette delte link?", - "remove_assets_title": "Fjern aktiver?", + "remove_assets_title": "Fjern mediefiler?", "remove_custom_date_range": "Fjern tilpasset datointerval", - "remove_deleted_assets": "Fjern fra offlinefiler", + "remove_deleted_assets": "Fjern slettede mediefiler", "remove_from_album": "Fjern fra album", "remove_from_favorites": "Fjern fra favoritter", "remove_from_shared_link": "Fjern fra delt link", @@ -1071,56 +1081,63 @@ "removed_from_archive": "Fjernet fra arkiv", "removed_from_favorites": "Fjernet fra favoritter", "removed_from_favorites_count": "{count, plural, other {Fjernede #}} fra favoritter", + "removed_memory": "Fjernede minde", + "removed_photo_from_memory": "Fjernede foto fra minde", "removed_tagged_assets": "Fjernede tag fra {count, plural, one {# aktiv} other {# aktiver}}", "rename": "Omdøb", "repair": "Reparér", - "repair_no_results_message": "Utrackede og manglende filer vil blive vist her", + "repair_no_results_message": "Usporede og manglende filer vil blive vist her", "replace_with_upload": "Erstat med upload", "repository": "Depot", "require_password": "Kræv adgangskode", "require_user_to_change_password_on_first_login": "Kræv at bruger skifter adgangskode ved første login", + "rescan": "Genopfrisk", "reset": "Nulstil", "reset_password": "Nulstil adgangskode", "reset_people_visibility": "Nulstil personsynlighed", "reset_to_default": "Nulstil til standard", "resolve_duplicates": "Løs dubletter", - "resolved_all_duplicates": "Løste alle dubletter", + "resolved_all_duplicates": "Alle dubletter løst", "restore": "Gendan", "restore_all": "Gendan alle", "restore_user": "Gendan bruger", - "restored_asset": "Gendannet aktiv", + "restored_asset": "Gendannet mediefilen", "resume": "Genoptag", "retry_upload": "Forsøg upload igen", "review_duplicates": "Gennemgå dubletter", "role": "Rolle", - "role_editor": "Editor", + "role_editor": "Redaktør", "role_viewer": "Seer", "save": "Gem", "saved_api_key": "Gemt API-nøgle", "saved_profile": "Gemte profil", "saved_settings": "Gemte indstillinger", "say_something": "Skriv noget", - "scan_all_libraries": "Skan gennem alle biblioteker", + "scan_all_libraries": "Skan alle biblioteker", "scan_library": "Skan", "scan_settings": "Skanningsindstillinger", "scanning_for_album": "Skanner efter albummer...", "search": "Søg", "search_albums": "Søg i albummer", "search_by_context": "Søg efter kontekst", + "search_by_description": "Søg efter beskrivelse", + "search_by_description_example": "Vandredag i Paris", "search_by_filename": "Søg efter filnavn eller filtypenavn", "search_by_filename_example": "dvs. IMG_1234.JPG eller PNG", "search_camera_make": "Søg efter kameraproducent...", "search_camera_model": "Søg efter kameramodel...", "search_city": "Søg efter by...", "search_country": "Søg efter land...", + "search_for": "Søg efter", "search_for_existing_person": "Søg efter eksisterende person", "search_no_people": "Ingen personer", "search_no_people_named": "Ingen personer med navnet \"{name}\"", "search_options": "Søgemuligheder", "search_people": "Søg i personer", "search_places": "Søg i steder", + "search_rating": "Søg efter vurdering...", "search_settings": "søgeindstillinger", - "search_state": "Søg efter stat...", + "search_state": "Søg efter lansdel...", "search_tags": "Søg tags...", "search_timezone": "Søg i tidszone...", "search_type": "Søg efter type", @@ -1141,7 +1158,7 @@ "select_photos": "Vælg billeder", "select_trash_all": "Vælg smid alle ud", "selected": "Valgt", - "selected_count": "{count, plural, other {# valgt}}", + "selected_count": "{count, plural, one {# valgt} other {# valgte}}", "send_message": "Send besked", "send_welcome_email": "Send velkomstemail", "server_offline": "Server Offline", @@ -1165,6 +1182,7 @@ "shared_from_partner": "Billeder fra {partner}", "shared_link_options": "Muligheder for delt link", "shared_links": "Delte links", + "shared_links_description": "Del billeder og videoer med et link", "shared_photos_and_videos_count": "{assetCount, plural, other {# delte billeder & videoer.}}", "shared_with_partner": "Delt med {partner}", "sharing": "Delte", @@ -1174,19 +1192,20 @@ "show_album_options": "Vis albumindstillinger", "show_albums": "Vis albummer", "show_all_people": "Vis alle personer", - "show_and_hide_people": "Vis & gem personer", + "show_and_hide_people": "Vis & skjul personer", "show_file_location": "Vis filplacering", "show_gallery": "Vis galleri", - "show_hidden_people": "Vis gemte personer", + "show_hidden_people": "Vis skjulte personer", "show_in_timeline": "Vis på tidslinje", "show_in_timeline_setting_description": "Vis billeder og videoer fra denne bruger på din tidslinje", "show_keyboard_shortcuts": "Vis tastaturgenveje", "show_metadata": "Vis metadata", - "show_or_hide_info": "Vis eller gem info", + "show_or_hide_info": "Vis eller skjul info", "show_password": "Vis adgangskode", "show_person_options": "Vis personindstillinger", "show_progress_bar": "Vis statuslinje", "show_search_options": "Vis søgeindstillinger", + "show_shared_links": "Vis delte links", "show_slideshow_transition": "Vis overgang til diasshow", "show_supporter_badge": "Supportermærke", "show_supporter_badge_description": "Vis et supportermærke", @@ -1206,7 +1225,7 @@ "sort_items": "Antal genstande", "sort_modified": "Ændret dato", "sort_oldest": "Ældste foto", - "sort_people_by_similarity": "Sorter folk efter lighed", + "sort_people_by_similarity": "Sorter efter personer der ligner hinanden", "sort_recent": "Seneste foto", "sort_title": "Titel", "source": "Kilde", @@ -1232,14 +1251,15 @@ "sunrise_on_the_beach": "Solopgang på stranden", "support": "Support", "support_and_feedback": "Support & Feedback", - "support_third_party_description": "Din Immich-installation blev pakket af en tredjepart. Problemer, du oplever, kan være forårsaget af denne pakke, så rejs venligst problemer med dem i første omgang ved at bruge nedenstående links.", + "support_third_party_description": "Din Immich-installation blev sammensat af en tredjepart. Problemer, du oplever, kan være forårsaget af denne udvikler, så rejs venligst problemer med dem i første omgang ved at bruge nedenstående links.", "swap_merge_direction": "Byt retning for sammenfletning", "sync": "Synkronisér", "tag": "Tag", - "tag_assets": "Tag aktiver", + "tag_assets": "Tag mediefiler", "tag_created": "Oprettet tag: {tag}", "tag_feature_description": "Gennemse billeder og videoer grupperet efter logiske tag-emner", "tag_not_found_question": "Kan du ikke finde et tag? Opret et nyt tag.", + "tag_people": "Tag personer", "tag_updated": "Opdateret tag: {tag}", "tagged_assets": "Tagget {count, plural, one {# aktiv} other {# aktiver}}", "tags": "Tags", @@ -1264,16 +1284,17 @@ "total_usage": "Samlet forbrug", "trash": "Papirkurv", "trash_all": "Smid alle ud", - "trash_count": "Skrald {count, number}", - "trash_delete_asset": "Papirkurv/slet aktiv", - "trash_no_results_message": "Udsmidte billeder og videoer vil kunne findes her.", + "trash_count": "Slet {count, number}", + "trash_delete_asset": "Flyt mediefil til Papirkurv", + "trash_no_results_message": "Billeder og videoer markeret til sletning vil blive vist her.", "trashed_items_will_be_permanently_deleted_after": "Mediefiler i skraldespanden vil blive slettet permanent efter {days, plural, one {# dag} other {# dage}}.", "type": "Type", "unarchive": "Afakivér", "unarchived_count": "{count, plural, other {Uarkiveret #}}", "unfavorite": "Fjern favorit", - "unhide_person": "Hold op med at gemme person væk", + "unhide_person": "Stop med at skjule person", "unknown": "Ukendt", + "unknown_country": "Ukendt land", "unknown_year": "Ukendt år", "unlimited": "Ubegrænset", "unlink_motion_video": "Fjern link til bevægelsesvideo", @@ -1287,18 +1308,18 @@ "unselect_all_duplicates": "Fjern markeringen af alle dubletter", "unstack": "Fjern fra stak", "unstacked_assets_count": "Ikke-stablet {count, plural, one {# aktiv} other {# aktiver}}", - "untracked_files": "Usporede filer", + "untracked_files": "Ikke overvågede filer", "untracked_files_decription": "Disse filer bliver ikke sporet af applikationen. De kan være resultatet af mislykkede flytninger, afbrudte uploads eller efterladt på grund af en fejl", "up_next": "Næste", "updated_password": "Opdaterede adgangskode", "upload": "Upload", - "upload_concurrency": "Uploadsamtidighed", + "upload_concurrency": "Upload samtidighed", "upload_errors": "Upload afsluttet med {count, plural, one {# fejl} other {# fejl}}. Opdater siden for at se nye uploadaktiver.", "upload_progress": "Resterende {remaining, number} - Behandlet {processed, number}/{total, number}", "upload_skipped_duplicates": "Sprang over {count, plural, one {# duplet aktiv} other {# duplikerede aktiver}}", "upload_status_duplicates": "Dubletter", "upload_status_errors": "Fejl", - "upload_status_uploaded": "Uploaded", + "upload_status_uploaded": "Uploadet", "upload_success": "Upload gennemført. Opdater siden for at se nye uploadaktiver.", "url": "URL", "usage": "Forbrug", @@ -1310,7 +1331,7 @@ "user_purchase_settings_description": "Administrer dit køb", "user_role_set": "Indstil {user} som {role}", "user_usage_detail": "Detaljer om brugers forbrug", - "user_usage_stats": "Konto anvendelsesstatistik", + "user_usage_stats": "Kontoens anvendelsesstatistik", "user_usage_stats_description": "Vis konto anvendelsesstatistik", "username": "Brugernavn", "users": "Brugere", @@ -1332,6 +1353,7 @@ "view_all": "Se alle", "view_all_users": "Se alle brugere", "view_in_timeline": "Se på tidslinjen", + "view_link": "Vis Link", "view_links": "Vis links", "view_name": "Se", "view_next_asset": "Se næste medie", @@ -1348,4 +1370,4 @@ "yes": "Ja", "you_dont_have_any_shared_links": "Du har ikke nogen delte links", "zoom_image": "Zoom billede" -} +} \ No newline at end of file diff --git a/i18n/de.json b/i18n/de.json index 3490d2c7df..dd8d9ed9a6 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -20,7 +20,7 @@ "add_partner": "Partner hinzufügen", "add_path": "Pfad hinzufügen", "add_photos": "Fotos hinzufügen", - "add_to": "Hinzufügen zu ...", + "add_to": "Hinzufügen zu …", "add_to_album": "Zu Album hinzufügen", "add_to_shared_album": "Zu geteiltem Album hinzufügen", "add_url": "URL hinzufügen", @@ -31,7 +31,7 @@ "add_exclusion_pattern_description": "Ausschlussmuster hinzufügen. Platzhalter, wie *, **, und ? werden unterstützt. Um alle Dateien in einem Verzeichnis namens „Raw\" zu ignorieren, „**/Raw/**“ verwenden. Um alle Dateien zu ignorieren, die auf „.tif“ enden, „**/*.tif“ verwenden. Um einen absoluten Pfad zu ignorieren, „/pfad/zum/ignorieren/**“ verwenden.", "asset_offline_description": "Diese Datei einer externen Bibliothek befindet sich nicht mehr auf der Festplatte und wurde in den Papierkorb verschoben. Falls die Datei innerhalb der Bibliothek verschoben wurde, überprüfe deine Zeitleiste auf die neue entsprechende Datei. Um diese Datei wiederherzustellen, stelle bitte sicher, dass Immich auf den unten stehenden Dateipfad zugreifen kann und scanne die Bibliothek.", "authentication_settings": "Authentifizierungseinstellungen", - "authentication_settings_description": "Passwort-, OAuth- und sonstigen Authentifizierungseinstellungen verwalten", + "authentication_settings_description": "Passwort-, OAuth- und sonstige Authentifizierungseinstellungen verwalten", "authentication_settings_disable_all": "Bist du sicher, dass du alle Anmeldemethoden deaktivieren willst? Die Anmeldung wird vollständig deaktiviert.", "authentication_settings_reenable": "Nutze einen Server-Befehl zur Reaktivierung.", "background_task_job": "Hintergrundaufgaben", @@ -41,6 +41,7 @@ "backup_settings": "Datensicherungs-Einstellungen", "backup_settings_description": "Datensicherungs-Einstellungen verwalten", "check_all": "Alle überprüfen", + "cleanup": "Aufräumen", "cleared_jobs": "Folgende Aufgaben zurückgesetzt: {job}", "config_set_by_file": "Ist derzeit in einer Konfigurationsdatei festgelegt", "confirm_delete_library": "Bist du sicher, dass du die Bibliothek {library} löschen willst?", @@ -96,7 +97,7 @@ "library_scanning_enable_description": "Regelmäßiges Scannen der Bibliothek aktivieren", "library_settings": "Externe Bibliothek", "library_settings_description": "Einstellungen externer Bibliotheken verwalten", - "library_tasks_description": "Diese Aufgabe aktualisiert und überprüft die Bibliotheken", + "library_tasks_description": "Überprüfe externe Bibliotheken auf neue oder veränderte Medien", "library_watching_enable_description": "Überwache externe Bibliotheken auf Dateiänderungen", "library_watching_settings": "Bibliotheksüberwachung (EXPERIMENTELL)", "library_watching_settings_description": "Automatisch auf geänderte Dateien prüfen", @@ -131,7 +132,7 @@ "machine_learning_smart_search_description": "Semantische Bildsuche mittels CLIP-Einbettungen", "machine_learning_smart_search_enabled": "Intelligente Suche aktivieren", "machine_learning_smart_search_enabled_description": "Ist diese Option deaktiviert, werden die Bilder nicht für die intelligente Suche verwendet.", - "machine_learning_url_description": "Die URL des Servers für maschinelles Lernen. Wenn mehr als eine URL angegeben wird, wird jeder Server einzeln ausprobiert, bis einer erfolgreich antwortet, und zwar in der Reihenfolge vom ersten bis zum letzten.", + "machine_learning_url_description": "Die URL des Servers für maschinelles Lernen. Wenn mehr als eine URL angegeben wird, wird jeder Server einzeln ausprobiert, bis einer erfolgreich antwortet, und zwar in der Reihenfolge vom ersten bis zum letzten. Server die nicht antworten werden temporär ignoriert, bis sie wieder verfügbar sind.", "manage_concurrency": "Gleichzeitige Ausführungen verwalten", "manage_log_settings": "Log-Einstellungen verwalten", "map_dark_style": "Dunkler Stil", @@ -147,6 +148,8 @@ "map_settings": "Karte", "map_settings_description": "Karten- und GPS-Einstellungen verwalten", "map_style_description": "URL zu einem style.json Karten-Theme", + "memory_cleanup_job": "Erinnerungen aufräumen", + "memory_generate_job": "Erinnerungen Generierung", "metadata_extraction_job": "Metadaten extrahieren", "metadata_extraction_job_description": "Extrahieren von Metadaten, wie zum Beispiel GPS, Gesichtern und Auflösung aus jeder Datei", "metadata_faces_import_setting": "Import von Gesichtern aktivieren", @@ -187,7 +190,7 @@ "oauth_issuer_url": "Aussteller-URL", "oauth_mobile_redirect_uri": "Mobile Umleitungs-URI", "oauth_mobile_redirect_uri_override": "Mobile Umleitungs-URI überschreiben", - "oauth_mobile_redirect_uri_override_description": "Einschalten, wenn der OAuth-Provider keine mobile URI wie '{callback}' erlaubt", + "oauth_mobile_redirect_uri_override_description": "Einschalten, wenn der OAuth-Anbieter keine mobile URI wie '{callback}' erlaubt", "oauth_profile_signing_algorithm": "Algorithmus zur Profilsignierung", "oauth_profile_signing_algorithm_description": "Dieser Algorithmus wird für die Signatur des Benutzerprofils verwendet.", "oauth_scope": "Umfang", @@ -219,7 +222,7 @@ "reset_settings_to_default": "Einstellungen auf Standard zurücksetzen", "reset_settings_to_recent_saved": "Einstellungen auf die zuletzt gespeicherten Einstellungen zurücksetzen", "scanning_library": "Bibliothek scannen", - "search_jobs": "Aufgaben suchen...", + "search_jobs": "Suchaufgaben…", "send_welcome_email": "Begrüssungsmail senden", "server_external_domain_settings": "Externe Domain", "server_external_domain_settings_description": "Domäne für öffentlich freigegebene Links, einschließlich http(s)://", @@ -240,7 +243,7 @@ "storage_template_hash_verification_enabled_description": "Aktiviert die Hash-Verifizierung. Deaktiviere diese Option nur, wenn du dir über die damit verbundenen Auswirkungen im Klaren bist", "storage_template_migration": "Migration von Speichervorlagen", "storage_template_migration_description": "Diese Aufgabe wendet die aktuelle {template} auf zuvor hochgeladene Dateien an", - "storage_template_migration_info": "Vorlagenänderungen gelten nur für neue Dateien. Um die Vorlage rückwirkend auf bereits hochgeladene Assets anzuwenden, führe den {job} aus.", + "storage_template_migration_info": "Die Vorlage wird alle Dateierweiterungen in Kleinbuchstaben umwandeln. Vorlagenänderungen gelten nur für neue Dateien. Um die Vorlage rückwirkend auf bereits hochgeladene Assets anzuwenden, führe den {job} aus.", "storage_template_migration_job": "Speichervorlagenmigrations-Aufgabe", "storage_template_more_details": "Weitere Details zu dieser Funktion findest du unter Speichervorlage und dessen Implikationen", "storage_template_onboarding_description": "Wenn aktiviert, sortiert diese Funktion Dateien automatisch basierend auf einer benutzerdefinierten Vorlage. Aufgrund von Stabilitätsproblemen ist die Funktion standardmäßig deaktiviert. Weitere Informationen findest du in der Dokumentation.", @@ -290,7 +293,7 @@ "transcoding_constant_rate_factor_description": "Videoqualitätsstufe. Typische Werte sind 23 für H.264, 28 für HEVC, 31 für VP9 und 35 für AV1. Ein niedrigerer Wert ist besser, erzeugt aber größere Dateien.", "transcoding_disabled_description": "Videos nicht transkodieren, dies kann die Wiedergabe auf manchen Geräten beeinträchtigen", "transcoding_encoding_options": "Kodierungsoptionen", - "transcoding_encoding_options_description": "Setze Codec, Auflösung, Qualität und andere Optionen für kodierte Videos", + "transcoding_encoding_options_description": "Setze Codec, Auflösung, Qualität und andere Optionen für die kodierten Videos", "transcoding_hardware_acceleration": "Hardware-Beschleunigung", "transcoding_hardware_acceleration_description": "Experimentell; viel schneller, aber bei gleicher Bitrate mit geringerer Qualität", "transcoding_hardware_decoding": "Hardware-Dekodierung", @@ -299,7 +302,7 @@ "transcoding_max_b_frames": "Maximale B-Frames", "transcoding_max_b_frames_description": "Höhere Werte verbessern die Komprimierungseffizienz, verlangsamen aber die Kodierung. Ist möglicherweise nicht mit der Hardware-Beschleunigung älterer Geräte kompatibel. 0 deaktiviert die B-Frames, während -1 diesen Wert automatisch setzt.", "transcoding_max_bitrate": "Maximale Bitrate", - "transcoding_max_bitrate_description": "Die Festlegung einer maximalen Bitrate kann die Dateigrößen vorhersagbarer machen, ohne dass die Qualität darunter leidet. Bei 720p sind typische Werte 2600k für VP9 oder HEVC oder 4500k für H.264. Deaktiviert, wenn der Wert auf 0 gesetzt ist.", + "transcoding_max_bitrate_description": "Die Festlegung einer maximalen Bitrate kann die Dateigrößen vorhersagbarer machen, ohne dass die Qualität darunter leidet. Bei 720p sind typische Werte 2600 kbit/s für VP9 oder HEVC oder 4500 kbit/s für H.264. Deaktiviert, wenn der Wert auf 0 gesetzt ist.", "transcoding_max_keyframe_interval": "Maximales Keyframe-Intervall", "transcoding_max_keyframe_interval_description": "Legt den maximalen Frame-Abstand zwischen Keyframes fest. Niedrigere Werte verschlechtern die Komprimierungseffizienz, verbessern aber die Suchzeiten und können die Qualität in Szenen mit schnellen Bewegungen verbessern. Bei 0 wird dieser Wert automatisch eingestellt.", "transcoding_optimal_description": "Videos mit einer höheren Auflösung als der Zielauflösung oder in einem nicht akzeptierten Format", @@ -313,7 +316,7 @@ "transcoding_reference_frames_description": "Die Anzahl der Bilder, auf die bei der Komprimierung eines bestimmten Bildes Bezug genommen wird. Höhere Werte verbessern die Komprimierungseffizienz, verlangsamen aber die Kodierung. 0 setzt diesen Wert automatisch.", "transcoding_required_description": "Nur Videos in einem nicht akzeptierten Format", "transcoding_settings": "Einstellungen für die Videotranskodierung", - "transcoding_settings_description": "Verwalten welche Videos transkodiert werden und wie diese verarbeitet werden", + "transcoding_settings_description": "Verwalten welche Videos transkodiert und wie diese verarbeitet werden", "transcoding_target_resolution": "Ziel-Auflösung", "transcoding_target_resolution_description": "Höhere Auflösungen können mehr Details erhalten, benötigen aber mehr Zeit für die Codierung, haben größere Dateigrößen und können die Reaktionszeit der Anwendung beeinträchtigen.", "transcoding_temporal_aq": "Temporäre AQ", @@ -391,6 +394,7 @@ "allow_edits": "Bearbeiten erlauben", "allow_public_user_to_download": "Erlaube öffentlichen Benutzern, herunterzuladen", "allow_public_user_to_upload": "Erlaube öffentlichen Benutzern, hochzuladen", + "alt_text_qr_code": "QR-Code Bild", "anti_clockwise": "Gegen den Uhrzeigersinn", "api_key": "API-Schlüssel", "api_key_description": "Dieser Wert wird nur einmal angezeigt. Bitte kopiere ihn, bevor du das Fenster schließt.", @@ -406,17 +410,17 @@ "are_these_the_same_person": "Ist das dieselbe Person?", "are_you_sure_to_do_this": "Bist du sicher, dass du das tun willst?", "asset_added_to_album": "Zum Album hinzugefügt", - "asset_adding_to_album": "Hinzufügen zum Album...", + "asset_adding_to_album": "Hinzufügen zum Album…", "asset_description_updated": "Die Beschreibung der Datei wurde aktualisiert", "asset_filename_is_offline": "Datei {filename} ist offline", "asset_has_unassigned_faces": "Datei hat nicht zugewiesene Gesichter", - "asset_hashing": "Berechnung des Hashwerts...", + "asset_hashing": "Berechne Prüfsumme…", "asset_offline": "Datei offline", "asset_offline_description": "Diese externe Datei ist nicht mehr auf dem Datenträger vorhanden. Bitte wende dich an deinen Immich-Administrator, um Hilfe zu erhalten.", "asset_skipped": "Übersprungen", "asset_skipped_in_trash": "Im Papierkorb", "asset_uploaded": "Hochgeladen", - "asset_uploading": "Hochladen...", + "asset_uploading": "Hochladen…", "assets": "Dateien", "assets_added_count": "{count, plural, one {# Datei} other {# Dateien}} hinzugefügt", "assets_added_to_album_count": "{count, plural, one {# Datei} other {# Dateien}} zum Album hinzugefügt", @@ -481,6 +485,7 @@ "comments_are_disabled": "Kommentare sind deaktiviert", "confirm": "Bestätigen", "confirm_admin_password": "Administrator Passwort bestätigen", + "confirm_delete_face": "Bist du sicher dass du das Gesicht von {name} aus der Datei entfernen willst?", "confirm_delete_shared_link": "Bist du sicher, dass du diesen geteilten Link löschen willst?", "confirm_keep_this_delete_others": "Alle anderen Dateien im Stapel bis auf diese werden gelöscht. Bist du sicher, dass du fortfahren möchten?", "confirm_password": "Passwort bestätigen", @@ -533,6 +538,7 @@ "delete_album": "Album löschen", "delete_api_key_prompt": "Bist du sicher, dass du diesen API-Schlüssel löschen willst?", "delete_duplicates_confirmation": "Bist du sicher, dass du diese Duplikate endgültig löschen willst?", + "delete_face": "Gesicht löschen", "delete_key": "Schlüssel löschen", "delete_library": "Bibliothek löschen", "delete_link": "Link löschen", @@ -600,6 +606,7 @@ "enabled": "Aktiviert", "end_date": "Enddatum", "error": "Fehler", + "error_delete_face": "Fehler beim Löschen des Gesichts", "error_loading_image": "Fehler beim Laden des Bildes", "error_title": "Fehler - Etwas ist schief gelaufen", "errors": { @@ -687,7 +694,7 @@ "unable_to_load_liked_status": "Gewünschter Status kann nicht geladen werden", "unable_to_log_out_all_devices": "Konnte nicht von allen Geräten abmelden", "unable_to_log_out_device": "Konnte nicht vom Gerät abmelden", - "unable_to_login_with_oauth": "Konnte nicht mit OAuth anmelden", + "unable_to_login_with_oauth": "Anmeldung mit OAuth nicht möglich", "unable_to_play_video": "Das Video kann nicht wiedergegeben werden", "unable_to_reassign_assets_existing_person": "Kann Dateien nicht {name, select, null {einer vorhandenen Person} other {{name}}} zuweisen", "unable_to_reassign_assets_new_person": "Dateien konnten nicht einer neuen Person zugeordnet werden", @@ -766,8 +773,10 @@ "go_to_folder": "Gehe zu Ordner", "go_to_search": "Zur Suche gehen", "group_albums_by": "Alben gruppieren nach...", + "group_country": "Nach Land gruppieren", "group_no": "Keine Gruppierung", "group_owner": "Gruppierung nach Besitzer", + "group_places_by": "Orte gruppieren nach...", "group_year": "Gruppierung nach Jahr", "has_quota": "Kontingent", "hi_user": "Hallo {name} ({email})", @@ -800,6 +809,7 @@ "include_shared_albums": "Freigegebene Alben einbeziehen", "include_shared_partner_assets": "Geteilte Partner-Dateien mit einbeziehen", "individual_share": "Individuelle Freigabe", + "individual_shares": "Individuelles Teilen", "info": "Info", "interval": { "day_at_onepm": "Täglich um 13:00 Uhr", @@ -822,6 +832,7 @@ "latest_version": "Aktuellste Version", "latitude": "Breitengrad", "leave": "Verlassen", + "lens_model": "Objektivmodell", "let_others_respond": "Antworten zulassen", "level": "Level", "library": "Bibliothek", @@ -830,7 +841,7 @@ "like_deleted": "Like gelöscht", "link_motion_video": "Bewegungsvideo verknüpfen", "link_options": "Link-Optionen", - "link_to_oauth": "Link zu OAuth", + "link_to_oauth": "Mit OAuth verknüpfen", "linked_oauth_account": "Verknüpftes OAuth-Konto", "list": "Liste", "loading": "Laden", @@ -855,7 +866,7 @@ "manage_your_account": "Dein Konto verwalten", "manage_your_api_keys": "Deine API-Schlüssel verwalten", "manage_your_devices": "Deine eingeloggten Geräte verwalten", - "manage_your_oauth_connection": "Deine OAuth-Verbindung verwalten", + "manage_your_oauth_connection": "Deine OAuth-Verknüpfung verwalten", "map": "Karte", "map_marker_for_images": "Kartenmarkierung für Bilder, die in {city}, {country} aufgenommen wurden", "map_marker_with_image": "Kartenmarkierung mit Bild", @@ -880,6 +891,7 @@ "month": "Monat", "more": "Mehr", "moved_to_trash": "In den Papierkorb verschoben", + "mute_memories": "Erinnerungen stumm schalten", "my_albums": "Meine Alben", "name": "Name", "name_or_nickname": "Name oder Nickname", @@ -975,6 +987,7 @@ "permanently_deleted_asset": "Endgültig gelöschtes Objekt", "permanently_deleted_assets_count": "{count, plural, one {# Datei} other {# Dateien}} endgültig gelöscht", "person": "Person", + "person_birthdate": "Geboren am {date}", "person_hidden": "{name}{hidden, select, true { (verborgen)} other {}}", "photo_shared_all_users": "Es sieht so aus, als hättest du deine Fotos mit allen Benutzern geteilt oder du hast keine Benutzer, mit denen du teilen kannst.", "photos": "Fotos", @@ -984,6 +997,7 @@ "pick_a_location": "Wähle einen Ort", "place": "Ort", "places": "Orte", + "places_count": "{count, plural, one {{count, number} Ort} other {{count, number} Orte}}", "play": "Abspielen", "play_memories": "Erinnerungen abspielen", "play_motion_photo": "Bewegte Bilder abspielen", @@ -1071,6 +1085,8 @@ "removed_from_archive": "Aus dem Archiv entfernt", "removed_from_favorites": "Aus den Favoriten entfernt", "removed_from_favorites_count": "{count, plural, other {#}} aus den Favoriten entfernt", + "removed_memory": "Erinnerung entfernt", + "removed_photo_from_memory": "Foto aus Erinnerung entfernt", "removed_tagged_assets": "Tag von {count, plural, one {# Datei} other {# Dateien}} entfernt", "rename": "Umbenennen", "repair": "Reparatur", @@ -1079,6 +1095,7 @@ "repository": "Repository", "require_password": "Passwort erforderlich", "require_user_to_change_password_on_first_login": "Benutzer muss das Passwort beim ersten Login ändern", + "rescan": "Erneut scannen", "reset": "Zurücksetzen", "reset_password": "Passwort zurücksetzen", "reset_people_visibility": "Sichtbarkeit von Personen zurücksetzen", @@ -1107,18 +1124,22 @@ "search": "Suche", "search_albums": "Album suchen", "search_by_context": "Suche nach Kontext", + "search_by_description": "Nach Beschreibung suchen", + "search_by_description_example": "Wandern in Sapa", "search_by_filename": "Suche nach Dateiname oder -erweiterung", "search_by_filename_example": "z.B. IMG_1234.JPG oder PNG", "search_camera_make": "Suche nach Kameramarke...", "search_camera_model": "Suche nach Kameramodell...", "search_city": "Suche nach Stadt...", "search_country": "Suche nach Land...", + "search_for": "Suche nach", "search_for_existing_person": "Suche nach vorhandener Person", "search_no_people": "Keine Personen", "search_no_people_named": "Keine Person mit dem Namen \"{name}\"", "search_options": "Suchoptionen", "search_people": "Suche nach Personen", "search_places": "Suche nach Orten", + "search_rating": "Suche nach Bewertung...", "search_settings": "Suche nach Einstellungen", "search_state": "Suche nach Bundesland / Provinz...", "search_tags": "Sache nach Tags...", @@ -1165,6 +1186,7 @@ "shared_from_partner": "Fotos von {partner}", "shared_link_options": "Optionen für geteilten Link", "shared_links": "Geteilte Links", + "shared_links_description": "Teile Fotos und Videos mit einem Link", "shared_photos_and_videos_count": "{assetCount, plural, one {# geteiltes Foto oder Video.} other {# geteilte Fotos & Videos.}}", "shared_with_partner": "Geteilt mit {partner}", "sharing": "Geteiltes", @@ -1187,6 +1209,7 @@ "show_person_options": "Personen-Optionen anzeigen", "show_progress_bar": "Fortschrittsbalken anzeigen", "show_search_options": "Suchoptionen anzeigen", + "show_shared_links": "Zeige geteilte Links", "show_slideshow_transition": "Slideshow-Übergang anzeigen", "show_supporter_badge": "Unterstützerabzeichen", "show_supporter_badge_description": "Zeige Unterstützerabzeichen", @@ -1240,6 +1263,7 @@ "tag_created": "Tag erstellt: {tag}", "tag_feature_description": "Durchsuchen von Fotos und Videos, gruppiert nach logischen Tag-Themen", "tag_not_found_question": "Kein Tag zu finden? Erstelle einen neuen Tag.", + "tag_people": "Personen taggen", "tag_updated": "Tag aktualisiert: {tag}", "tagged_assets": "{count, plural, one {# Datei} other {# Dateien}} getagged", "tags": "Tags", @@ -1274,11 +1298,13 @@ "unfavorite": "Entfavorisieren", "unhide_person": "Person einblenden", "unknown": "Unbekannt", + "unknown_country": "Unbekanntes Land", "unknown_year": "Unbekanntes Jahr", "unlimited": "Unlimitiert", "unlink_motion_video": "Verknüpfung zum Bewegungsvideo aufheben", "unlink_oauth": "OAuth entfernen", - "unlinked_oauth_account": "Nicht verknüpftes OAuth-Konto", + "unlinked_oauth_account": "OAuth-Konto entfernt", + "unmute_memories": "Stummschaltung für Erinnerungen aufheben", "unnamed_album": "Unbenanntes Album", "unnamed_album_delete_confirmation": "Bist du sicher, dass du dieses Album löschen willst?", "unnamed_share": "Unbenannte Freigabe", @@ -1332,6 +1358,7 @@ "view_all": "Alles anzeigen", "view_all_users": "Alle Nutzer anzeigen", "view_in_timeline": "In Zeitleiste anzeigen", + "view_link": "Link anzeigen", "view_links": "Links anzeigen", "view_name": "Ansicht", "view_next_asset": "Nächste Datei anzeigen", @@ -1348,4 +1375,4 @@ "yes": "Ja", "you_dont_have_any_shared_links": "Du hast keine geteilten Links", "zoom_image": "Bild vergrößern" -} +} \ No newline at end of file diff --git a/i18n/el.json b/i18n/el.json index ed06812e77..6c2bee5c79 100644 --- a/i18n/el.json +++ b/i18n/el.json @@ -20,7 +20,7 @@ "add_partner": "Προσθήκη συνεργάτη", "add_path": "Προσθήκη διαδρομής", "add_photos": "Προσθήκη φωτογραφιών", - "add_to": "Προσθήκη σε...", + "add_to": "Προσθήκη σε…", "add_to_album": "Προσθήκη σε άλμπουμ", "add_to_shared_album": "Προσθήκη σε κοινόχρηστο άλμπουμ", "add_url": "Προσθήκη Συνδέσμου", @@ -114,24 +114,24 @@ "machine_learning_facial_recognition": "Αναγνώριση Προσώπου", "machine_learning_facial_recognition_description": "Εντοπισμός, αναγνώριση και ομαδοποίηση προσώπων που υπάρχουν σε εικόνες", "machine_learning_facial_recognition_model": "Μοντέλο αναγνώρισης προσώπου", - "machine_learning_facial_recognition_model_description": "Τα μοντέλα παρατίθενται με φθίνουσα σειρά μεγέθους. Τα μεγαλύτερα μοντέλα είναι πιο αργά και χρησιμοποιούν περισσότερη μνήμη, αλλά παράγουν καλύτερα αποτελέσματα. Σημειώστε ότι πρέπει να εκτελέσετε ξανά την εργασία Ανίχνευση προσώπου για όλες τις εικόνες κατά την αλλαγή ενός μοντέλου.", + "machine_learning_facial_recognition_model_description": "Τα μοντέλα παρατίθενται με φθίνουσα σειρά μεγέθους. Τα μεγαλύτερα μοντέλα είναι πιο αργά και χρησιμοποιούν περισσότερη μνήμη, αλλά παράγουν καλύτερα αποτελέσματα. Σημειώστε ότι πρέπει να επανεκτελέσετε την εργασία \"Ανίχνευση Προσώπου\" για όλες τις εικόνες μετά την αλλαγή μοντέλου.", "machine_learning_facial_recognition_setting": "Ενεργοποίηση αναγνώρισης προσώπου", "machine_learning_facial_recognition_setting_description": "Αν απενεργοποιηθεί, οι εικόνες δεν θα κωδικοποιούνται για αναγνώριση προσώπου και δεν θα συμπληρώνουν την ενότητα Άτομα στη σελίδα Περιήγησης.", "machine_learning_max_detection_distance": "Μέγιστη απόσταση ανίχνευσης", "machine_learning_max_detection_distance_description": "Η μέγιστη απόσταση μεταξύ δύο εικόνων για να θεωρηθούν διπλότυπες, που κυμαίνεται από 0,001-0,1. Οι υψηλότερες τιμές θα εντοπίσουν περισσότερες διπλότυπες, αλλά μπορεί να οδηγήσουν σε ψευδώς θετικά αποτελέσματα.", "machine_learning_max_recognition_distance": "Μέγιστη απόσταση αναγνώρισης", - "machine_learning_max_recognition_distance_description": "Η μέγιστη απόσταση μεταξύ δύο προσώπων για να θεωρείται το ίδιο άτομο, που κυμαίνεται από 0-2. Η μείωση αυτή μπορεί να αποτρέψει την επισήμανση δύο ατόμων ως το ίδιο άτομο, ενώ η αύξηση της μπορεί να αποτρέψει την επισήμανση του ίδιου ατόμου ως δύο διαφορετικών ατόμων. Λάβετε υπόψη ότι είναι πιο εύκολο να συγχωνεύσετε δύο άτομα παρά να χωρίσετε ένα άτομο στα δύο, οπότε προτιμήστε ένα χαμηλότερο όριο, όταν αυτό είναι δυνατό.", + "machine_learning_max_recognition_distance_description": "Η μέγιστη απόσταση μεταξύ δύο προσώπων για να θεωρείται το ίδιο άτομο, που κυμαίνεται από 0-2. Η μείωση αυτής της τιμής μπορεί να αποτρέψει την επισήμανση δύο ατόμων ως το ίδιο άτομο, ενώ η αύξηση της μπορεί να αποτρέψει την επισήμανση του ίδιου ατόμου ως δύο διαφορετικών ατόμων. Λάβετε υπόψη ότι είναι πιο εύκολο να συγχωνεύσετε δύο άτομα παρά να χωρίσετε ένα άτομο στα δύο, οπότε προτιμήστε ένα χαμηλότερο όριο, όταν αυτό είναι δυνατό.", "machine_learning_min_detection_score": "Ελάχιστο σκορ ανίχνευσης", "machine_learning_min_detection_score_description": "Ελάχιστο σκορ εμπιστοσύνης για ανίχνευση προσώπου από 0-1. Οι χαμηλότερες τιμές θα εντοπίσουν περισσότερα πρόσωπα, αλλά μπορεί να οδηγήσουν σε ψευδώς θετικά αποτελέσματα.", "machine_learning_min_recognized_faces": "Ελάχιστα αναγνωρισμένα πρόσωπα", "machine_learning_min_recognized_faces_description": "Ο ελάχιστος αριθμός αναγνωρισμένων προσώπων για ένα άτομο που θα δημιουργηθεί. Η αύξηση αυτή καθιστά την Αναγνώριση Προσώπου πιο ακριβή με το κόστος να αυξηθεί η πιθανότητα να μην εκχωρηθεί ένα πρόσωπο σε ένα άτομο.", - "machine_learning_settings": "Ρυθμίσεις Μηχανικής Εκμάθησης", - "machine_learning_settings_description": "Διαχειριστείτε τις λειτουργίες και τις ρυθμίσεις μηχανικής εκμάθησης", + "machine_learning_settings": "Ρυθμίσεις Μηχανικής Μάθησης", + "machine_learning_settings_description": "Διαχειριστείτε τις λειτουργίες και τις ρυθμίσεις μηχανικής μάθησης", "machine_learning_smart_search": "Έξυπνη Αναζήτηση", "machine_learning_smart_search_description": "Αναζητήστε εικόνες σημασιολογικά χρησιμοποιώντας ενσωματώσεις CLIP", "machine_learning_smart_search_enabled": "Ενεργοποίηση έξυπνης αναζήτησης", "machine_learning_smart_search_enabled_description": "Αν απενεργοποιηθεί, οι εικόνες δεν θα κωδικοποιούνται για έξυπνη αναζήτηση.", - "machine_learning_url_description": "Η διεύθυνση URL του διακομιστή μηχανικής εκμάθησης. Αν παρέχονται περισσότερες από μία διευθύνσεις URL, τότε, κάθε διακομιστής θα προσπαθήσει να συνδεθεί διαδοχικά, από την πρώτη μέχρι την τελευταία, έως ότου απαντήσει επιτυχώς.", + "machine_learning_url_description": "Η διεύθυνση URL του διακομιστή μηχανικής μάθησης. Αν δοθούν περισσότερες από μία διευθύνσεις URL, κάθε διακομιστής θα δοκιμάζεται διαδοχικά μέχρι να ανταποκριθεί ένας με επιτυχία, με τη σειρά από την πρώτη έως την τελευταία. Οι διακομιστές που δεν ανταποκρίνονται θα αγνοούνται προσωρινά μέχρι να επανέλθουν σε λειτουργία.", "manage_concurrency": "Διαχείριση ταυτόχρονη εκτέλεσης", "manage_log_settings": "Διαχείριση ρυθμίσεων αρχείου καταγραφής", "map_dark_style": "Σκούρο Θέμα", @@ -147,6 +147,8 @@ "map_settings": "Χάρτης", "map_settings_description": "Διαχείριση ρυθμίσεων χάρτη", "map_style_description": "URL προς αρχείο θέματος του χάρτη style.json", + "memory_cleanup_job": "Καθαρισμός μνήμης", + "memory_generate_job": "Δημιουργία μνήμης", "metadata_extraction_job": "Εξαγωγή μεταδεδομένων", "metadata_extraction_job_description": "Εξαγωγή μεταδεδομένων από κάθε αρχείο, όπως τοποθεσία, πρόσωπα και ανάλυση", "metadata_faces_import_setting": "Ενεργοποίηση εισαγωγής προσώπων", @@ -219,7 +221,7 @@ "reset_settings_to_default": "Επαναφορά προεπιλεγμένων ρυθμίσεων", "reset_settings_to_recent_saved": "Επαναφορά ρυθμίσεων στις πρόσφατα αποθηκευμένες ρυθμίσεις", "scanning_library": "Σάρωση βιβλιοθήκης", - "search_jobs": "Αναζήτηση εργασιών...", + "search_jobs": "Αναζήτηση εργασιών…", "send_welcome_email": "Αποστολή email καλωσορίσματος", "server_external_domain_settings": "Εξωτερική διεύθυνση τομέα", "server_external_domain_settings_description": "Διεύθυνση τομέα για δημόσιους κοινούς συνδέσμους, περιλαμβανομένου του http(s)://", @@ -232,7 +234,7 @@ "sidecar_job": "Μεταδεδομένα συνοδευτικού αρχείου", "sidecar_job_description": "Ανακάλυψη ή συγχρονισμός των μεταδεδομένων του συνοδευτικού αρχείου από το σύστημα αρχείων", "slideshow_duration_description": "Αριθμός δευτερολέπτων για την εμφάνιση κάθε εικόνας", - "smart_search_job_description": "Εκτέλεση της μηχανικής εκμάθησης, σε αρχεία, για την υποστήριξη της έξυπνης αναζήτησης", + "smart_search_job_description": "Εκτέλεση της μηχανικής μάθησης, σε αρχεία, για την υποστήριξη της έξυπνης αναζήτησης", "storage_template_date_time_description": "Η χρονική σήμανση της δημιουργίας του αρχείου, χρησιμοποιείται για τις πληροφορίες ημερομηνίας και ώρας", "storage_template_date_time_sample": "Χρόνος δείγματος {date}", "storage_template_enable_description": "Ενεργοποίηση του μηχανισμού των προτύπων αποθήκευσης", @@ -299,7 +301,7 @@ "transcoding_max_b_frames": "Μέγιστος αριθμός B-frames(Bidirectional Predictive Frames)", "transcoding_max_b_frames_description": "Οι υψηλότερες τιμές βελτιώνουν την αποδοτικότητα της συμπίεσης, αλλά επιβραδύνουν την κωδικοποίηση. Ενδέχεται να μην είναι συμβατές με την επιτάχυνση υλικού σε παλαιότερες συσκευές. Η τιμή 0 απενεργοποιεί τα B-frames, ενώ η -1, τη ρυθμίζει αυτόματα.", "transcoding_max_bitrate": "Μέγιστος ρυθμός μετάδοσης (bitrate)", - "transcoding_max_bitrate_description": "Η ρύθμιση ενός μέγιστου ρυθμού μετάδοσης(bitrate) μπορεί να κάνει το μέγεθος των αρχείων πιο προβλέψιμο, αλλά με ένα μικρό κόστος στην ποιότητα. Στην ανάλυση των 720p, οι τυπικές τιμές είναι 2600k για VP9 ή HEVC, ή 4500k για H.264. Απενεργοποιείται εάν οριστεί σε 0.", + "transcoding_max_bitrate_description": "Η ρύθμιση ενός μέγιστου ρυθμού μετάδοσης(bitrate) μπορεί να κάνει το μέγεθος των αρχείων πιο προβλέψιμο, αλλά με ένα μικρό κόστος στην ποιότητα. Στην ανάλυση των 720p, οι τυπικές τιμές είναι 2600 kbit/s για VP9 ή HEVC, ή 4500 kbit/s για H.264. Απενεργοποιείται εάν οριστεί σε 0.", "transcoding_max_keyframe_interval": "Μέγιστο χρονικό διάστημα μεταξύ των καρέ αναφοράς (keyframe)", "transcoding_max_keyframe_interval_description": "Ορίζει το μέγιστο διάστημα μεταξύ των καρέ αναφοράς. Χαμηλότερες τιμές μειώνουν την αποδοτικότητα συμπίεσης, αλλά βελτιώνουν τον χρόνο αναζήτησης και μπορεί να βελτιώσουν την ποιότητα σε σκηνές με γρήγορη κίνηση. Η τιμή 0 ρυθμίζει αυτό το διάστημα αυτόματα.", "transcoding_optimal_description": "Βίντεο με ανώτερη ανάλυση από την επιθυμητή ή σε μη αποδεκτή μορφή", @@ -406,17 +408,17 @@ "are_these_the_same_person": "Είναι το ίδιο άτομο;", "are_you_sure_to_do_this": "Είστε σίγουροι ότι θέλετε να το κάνετε αυτό;", "asset_added_to_album": "Προστέθηκε στο άλμπουμ", - "asset_adding_to_album": "Προστίθεται στο άλμπουμ...", + "asset_adding_to_album": "Προστίθεται στο άλμπουμ…", "asset_description_updated": "Η περιγραφή του αντικειμένου έχει ενημερωθεί", "asset_filename_is_offline": "Το αντικείμενο {filename} είναι εκτός σύνδεσης", "asset_has_unassigned_faces": "Το αντικείμενο έχει μη ανατεθειμένα πρόσωπα", - "asset_hashing": "Δημιουργία κατακερματισμού...", + "asset_hashing": "Δημιουργία κατακερματισμού…", "asset_offline": "Αντικείμενο εκτός σύνδεσης", "asset_offline_description": "Αυτό το εξωτερικό αντικείμενο δεν βρέθηκε πλέον στον δίσκο. Παρακαλώ επικοινωνήστε με τον διαχειριστή του Immich για βοήθεια.", "asset_skipped": "Παραλείφθηκε", "asset_skipped_in_trash": "Στον κάδο απορριμμάτων", "asset_uploaded": "Ανεβάστηκε", - "asset_uploading": "Ανεβάζεται...", + "asset_uploading": "Ανεβάζεται…", "assets": "Αντικείμενα", "assets_added_count": "Προστέθηκε {count, plural, one {# αρχείο} other {# αρχεία}}", "assets_added_to_album_count": "Προστέθηκε {count, plural, one {# αρχείο} other {# αρχεία}} στο άλμπουμ", @@ -481,6 +483,7 @@ "comments_are_disabled": "Τα σχόλια είναι απενεργοποιημένα", "confirm": "Επιβεβαίωση", "confirm_admin_password": "Επιβεβαίωση κωδικού Διαχειριστή", + "confirm_delete_face": "Είστε σίγουροι ότι θέλετε να διαγράψετε το πρόσωπο του/της {name} από το στοιχείο;", "confirm_delete_shared_link": "Είστε σίγουροι ότι θέλετε να διαγράψετε αυτόν τον κοινόχρηστο σύνδεσμο;", "confirm_keep_this_delete_others": "Όλα τα άλλα στοιχεία της στοίβας θα διαγραφούν, εκτός από αυτό το στοιχείο. Είστε σίγουροι ότι θέλετε να συνεχίσετε;", "confirm_password": "Επιβεβαίωση κωδικού", @@ -533,6 +536,7 @@ "delete_album": "Διαγραφή άλμπουμ", "delete_api_key_prompt": "Είστε σίγουροι ότι θέλετε να διαγράψετε αυτό κλειδί API;", "delete_duplicates_confirmation": "Είστε σίγουροι ότι επιθυμείτε τη μόνιμη διαγραφή αυτών των διπλότυπων;", + "delete_face": "Διαγραφή προσώπου", "delete_key": "Διαγραφή κλειδιού", "delete_library": "Διαγραφή Βιβλιοθήκης", "delete_link": "Διαγραφή συνδέσμου", @@ -600,6 +604,7 @@ "enabled": "Ενεργοποιημένο", "end_date": "Τελική ημερομηνία", "error": "Σφάλμα", + "error_delete_face": "Σφάλμα διαγραφής προσώπου από το στοιχείο", "error_loading_image": "Σφάλμα κατά τη φόρτωση της εικόνας", "error_title": "Σφάλμα - Κάτι πήγε στραβά", "errors": { @@ -766,8 +771,10 @@ "go_to_folder": "Μετάβαση στο φάκελο", "go_to_search": "Πηγαίνετε στην αναζήτηση", "group_albums_by": "Ομαδοποίηση άλμπουμ κατά...", + "group_country": "Ομαδοποίηση κατά χώρα", "group_no": "Καμία ομοδοποίηση", "group_owner": "Ομαδοποίηση κατά ιδιοκτήτη", + "group_places_by": "Ομοδοποίηση τοποθεσιών κατά...", "group_year": "Ομαδοποίηση κατά έτος", "has_quota": "Έχει ποσόστωση", "hi_user": "Γειά σου {name} {email}", @@ -800,6 +807,7 @@ "include_shared_albums": "Συμπερίληψη διαμοιρασμένων άλμπουμ", "include_shared_partner_assets": "Συμπερίληψη των στοιχείων των συνεργατών που έχουν κοινοποιηθεί", "individual_share": "Μεμονωμένος διαμοιρασμός", + "individual_shares": "Μεμονωμένες κοινοποιήσεις", "info": "Πληροφορίες", "interval": { "day_at_onepm": "Κάθε μέρα στη 1μμ", @@ -822,6 +830,7 @@ "latest_version": "Τελευταία Έκδοση", "latitude": "Γεωγραφικό πλάτος", "leave": "Εγκατάλειψη", + "lens_model": "Μοντέλο φακού", "let_others_respond": "Επέτρεψε σε άλλους να απαντήσουν", "level": "Επίπεδο", "library": "Βιβλιοθήκη", @@ -984,6 +993,7 @@ "pick_a_location": "Επιλέξτε μια τοποθεσία", "place": "Τοποθεσία", "places": "Τοποθεσίες", + "places_count": "{count, plural, one {{count} Τοποθεσία} other {{count} Τοποθεσίες}}", "play": "Αναπαραγωγή", "play_memories": "Αναπαραγωγή αναμνήσεων", "play_motion_photo": "Αναπαραγωγή Κινούμενης Φωτογραφίας", @@ -1071,6 +1081,8 @@ "removed_from_archive": "Αφαιρέθηκε/καν από το Αρχείο", "removed_from_favorites": "Αφαιρέθηκε από τα αγαπημένα", "removed_from_favorites_count": "Αφαιρέθηκαν {count, plural, other {#}} από τα αγαπημένα", + "removed_memory": "Διαγραμμένη μνήμη", + "removed_photo_from_memory": "Διαγραμμένη φωτογραφία από τη μνήμη", "removed_tagged_assets": "Αφαιρέθηκε η ετικέτα από {count, plural, one {# στοιχείο} other {# στοιχεία}}", "rename": "Μετονομασία", "repair": "Επισκευή", @@ -1107,18 +1119,22 @@ "search": "Αναζήτηση", "search_albums": "Αναζήτηση άλμπουμ", "search_by_context": "Αναζήτηση με βάση το πλαίσιο", + "search_by_description": "Αναζήτηση με βάση την περιγραφή", + "search_by_description_example": "Ημερήσια πεζοπορία στο Πάπιγκο", "search_by_filename": "Αναζήτηση βάσει ονόματος αρχείου ή επέκτασης αρχείου", "search_by_filename_example": "π.χ. IMG_1234.JPG ή PNG", "search_camera_make": "Αναζήτηση κατασκευαστή κάμερας...", "search_camera_model": "Αναζήτηση μοντέλου κάμερας...", "search_city": "Αναζήτηση πόλης...", "search_country": "Αναζήτηση χώρας...", + "search_for": "Αναζήτηση για", "search_for_existing_person": "Αναζήτηση υπάρχοντος ατόμου", "search_no_people": "Κανένα άτομο", "search_no_people_named": "Κανένα άτομο με όνομα \"{name}\"", "search_options": "Επιλογές αναζήτησης", "search_people": "Αναζήτηση ατόμων", "search_places": "Αναζήτηση τοποθεσιών", + "search_rating": "Αναζήτηση κατά βαθμολογία...", "search_settings": "Ρυθμίσεις αναζήτησης", "search_state": "Αναζήτηση νομού...", "search_tags": "Αναζήτηση ετικετών...", @@ -1165,6 +1181,7 @@ "shared_from_partner": "Φωτογραφίες από {partner}", "shared_link_options": "Επιλογές κοινόχρηστου συνδέσμου", "shared_links": "Κοινόχρηστοι σύνδεσμοι", + "shared_links_description": "Μοιραστείτε φωτογραφίες και βίντεο με σύνδεσμο", "shared_photos_and_videos_count": "{assetCount, plural, other {# κοινόχρηστες φωτογραφίες & βίντεο.}}", "shared_with_partner": "Σε κοινή χρήση με {partner}", "sharing": "Κοινοποίηση", @@ -1187,6 +1204,7 @@ "show_person_options": "Εμφάνιση επιλογών ατόμου", "show_progress_bar": "Εμφάνιση γραμμής προόδου", "show_search_options": "Εμφάνιση επιλογών αναζήτησης", + "show_shared_links": "Εμφάνιση κοινών συνδέσμων", "show_slideshow_transition": "Εμφάνιση μετάβασης παρουσίασης", "show_supporter_badge": "Σήμα υποστηρικτή", "show_supporter_badge_description": "Εμφάνιση σήματος υποστηρικτή", @@ -1240,6 +1258,7 @@ "tag_created": "Δημιουργήθηκε ετικέτα: {tag}", "tag_feature_description": "Περιήγηση σε φωτογραφίες και βίντεο που είναι οργανωμένα σύμφωνα με λογικά θέματα ετικετών", "tag_not_found_question": "Δεν μπορείτε να βρείτε μια ετικέτα; Δημιουργήστε μια νέα ετικέτα.", + "tag_people": "Επισήμανση ατόμων", "tag_updated": "Ενημερώθηκε η ετικέτα: {tag}", "tagged_assets": "Ετικετοποιημένο/α {count, plural, one {# στοιχείο} other {# στοιχεία}}", "tags": "Ετικέτες", @@ -1274,6 +1293,7 @@ "unfavorite": "Αποεπιλογή από τα αγαπημένα", "unhide_person": "Αναίρεση απόκρυψης ατόμου", "unknown": "Άγνωστο", + "unknown_country": "Άγνωστη Χώρα", "unknown_year": "Άγνωστο Έτος", "unlimited": "Απεριόριστο", "unlink_motion_video": "Αποσυνδέστε το βίντεο κίνησης", @@ -1348,4 +1368,4 @@ "yes": "Ναι", "you_dont_have_any_shared_links": "Δεν έχετε κοινόχρηστους συνδέσμους", "zoom_image": "Ζουμ Εικόνας" -} +} \ No newline at end of file diff --git a/i18n/en.json b/i18n/en.json index ad48a96991..e8726b3d20 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -20,7 +20,7 @@ "add_partner": "Add partner", "add_path": "Add path", "add_photos": "Add photos", - "add_to": "Add to...", + "add_to": "Add to…", "add_to_album": "Add to album", "add_to_shared_album": "Add to shared album", "add_url": "Add URL", @@ -96,7 +96,7 @@ "library_scanning_enable_description": "Enable periodic library scanning", "library_settings": "External Library", "library_settings_description": "Manage external library settings", - "library_tasks_description": "Perform library tasks", + "library_tasks_description": "Scan external libraries for new and/or changed assets", "library_watching_enable_description": "Watch external libraries for file changes", "library_watching_settings": "Library watching (EXPERIMENTAL)", "library_watching_settings_description": "Automatically watch for changed files", @@ -131,7 +131,7 @@ "machine_learning_smart_search_description": "Search for images semantically using CLIP embeddings", "machine_learning_smart_search_enabled": "Enable smart search", "machine_learning_smart_search_enabled_description": "If disabled, images will not be encoded for smart search.", - "machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last.", + "machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last. Servers that don't respond will be temporarily ignored until they come back online.", "manage_concurrency": "Manage Concurrency", "manage_log_settings": "Manage log settings", "map_dark_style": "Dark style", @@ -219,7 +219,7 @@ "reset_settings_to_default": "Reset settings to default", "reset_settings_to_recent_saved": "Reset settings to the recent saved settings", "scanning_library": "Scanning library", - "search_jobs": "Search jobs...", + "search_jobs": "Search jobs…", "send_welcome_email": "Send welcome email", "server_external_domain_settings": "External domain", "server_external_domain_settings_description": "Domain for public shared links, including http(s)://", @@ -240,7 +240,7 @@ "storage_template_hash_verification_enabled_description": "Enables hash verification, don't disable this unless you're certain of the implications", "storage_template_migration": "Storage template migration", "storage_template_migration_description": "Apply the current {template} to previously uploaded assets", - "storage_template_migration_info": "Template changes will only apply to new assets. To retroactively apply the template to previously uploaded assets, run the {job}.", + "storage_template_migration_info": "The storage template will convert all extensions to lowercase. Template changes will only apply to new assets. To retroactively apply the template to previously uploaded assets, run the {job}.", "storage_template_migration_job": "Storage Template Migration Job", "storage_template_more_details": "For more details about this feature, refer to the Storage Template and its implications", "storage_template_onboarding_description": "When enabled, this feature will auto-organize files based on a user-defined template. Due to stability issues the feature has been turned off by default. For more information, please see the documentation.", @@ -299,7 +299,7 @@ "transcoding_max_b_frames": "Maximum B-frames", "transcoding_max_b_frames_description": "Higher values improve compression efficiency, but slow down encoding. May not be compatible with hardware acceleration on older devices. 0 disables B-frames, while -1 sets this value automatically.", "transcoding_max_bitrate": "Maximum bitrate", - "transcoding_max_bitrate_description": "Setting a max bitrate can make file sizes more predictable at a minor cost to quality. At 720p, typical values are 2600k for VP9 or HEVC, or 4500k for H.264. Disabled if set to 0.", + "transcoding_max_bitrate_description": "Setting a max bitrate can make file sizes more predictable at a minor cost to quality. At 720p, typical values are 2600 kbit/s for VP9 or HEVC, or 4500 kbit/s for H.264. Disabled if set to 0.", "transcoding_max_keyframe_interval": "Maximum keyframe interval", "transcoding_max_keyframe_interval_description": "Sets the maximum frame distance between keyframes. Lower values worsen compression efficiency, but improve seek times and may improve quality in scenes with fast movement. 0 sets this value automatically.", "transcoding_optimal_description": "Videos higher than target resolution or not in an accepted format", @@ -336,6 +336,7 @@ "untracked_files": "Untracked Files", "untracked_files_description": "These files are not tracked by the application. They can be the results of failed moves, interrupted uploads, or left behind due to a bug", "user_cleanup_job": "User cleanup", + "cleanup": "Cleanup", "user_delete_delay": "{user}'s account and assets will be scheduled for permanent deletion in {delay, plural, one {# day} other {# days}}.", "user_delete_delay_settings": "Delete delay", "user_delete_delay_settings_description": "Number of days after removal to permanently delete a user's account and assets. The user deletion job runs at midnight to check for users that are ready for deletion. Changes to this setting will be evaluated at the next execution.", @@ -352,6 +353,8 @@ "version_check_enabled_description": "Enable version check", "version_check_implications": "The version check feature relies on periodic communication with github.com", "version_check_settings": "Version Check", + "memory_cleanup_job": "Memory cleanup", + "memory_generate_job": "Memory generation", "version_check_settings_description": "Enable/disable the new version notification", "video_conversion_job": "Transcode videos", "video_conversion_job_description": "Transcode videos for wider compatibility with browsers and devices" @@ -391,6 +394,7 @@ "allow_edits": "Allow edits", "allow_public_user_to_download": "Allow public user to download", "allow_public_user_to_upload": "Allow public user to upload", + "alt_text_qr_code": "QR code image", "anti_clockwise": "Anti-clockwise", "api_key": "API Key", "api_key_description": "This value will only be shown once. Please be sure to copy it before closing the window.", @@ -406,17 +410,17 @@ "are_these_the_same_person": "Are these the same person?", "are_you_sure_to_do_this": "Are you sure you want to do this?", "asset_added_to_album": "Added to album", - "asset_adding_to_album": "Adding to album...", + "asset_adding_to_album": "Adding to album…", "asset_description_updated": "Asset description has been updated", "asset_filename_is_offline": "Asset {filename} is offline", "asset_has_unassigned_faces": "Asset has unassigned faces", - "asset_hashing": "Hashing...", + "asset_hashing": "Hashing…", "asset_offline": "Asset Offline", "asset_offline_description": "This external asset is no longer found on disk. Please contact your Immich administrator for help.", "asset_skipped": "Skipped", "asset_skipped_in_trash": "In trash", "asset_uploaded": "Uploaded", - "asset_uploading": "Uploading...", + "asset_uploading": "Uploading…", "assets": "Assets", "assets_added_count": "Added {count, plural, one {# asset} other {# assets}}", "assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album", @@ -481,6 +485,7 @@ "comments_are_disabled": "Comments are disabled", "confirm": "Confirm", "confirm_admin_password": "Confirm Admin Password", + "confirm_delete_face": "Are you sure you want to delete {name} face from the asset?", "confirm_delete_shared_link": "Are you sure you want to delete this shared link?", "confirm_keep_this_delete_others": "All other assets in the stack will be deleted except for this asset. Are you sure you want to continue?", "confirm_password": "Confirm password", @@ -523,16 +528,17 @@ "date_range": "Date range", "day": "Day", "deduplicate_all": "Deduplicate All", - "deduplication_info": "Deduplication Info", - "deduplication_info_description": "To automatically preselect assets and remove duplicates in bulk, we look at:", "deduplication_criteria_1": "Image size in bytes", "deduplication_criteria_2": "Count of EXIF data", + "deduplication_info": "Deduplication Info", + "deduplication_info_description": "To automatically preselect assets and remove duplicates in bulk, we look at:", "default_locale": "Default Locale", "default_locale_description": "Format dates and numbers based on your browser locale", "delete": "Delete", "delete_album": "Delete album", "delete_api_key_prompt": "Are you sure you want to delete this API key?", "delete_duplicates_confirmation": "Are you sure you want to permanently delete these duplicates?", + "delete_face": "Delete face", "delete_key": "Delete key", "delete_library": "Delete Library", "delete_link": "Delete link", @@ -600,6 +606,7 @@ "enabled": "Enabled", "end_date": "End date", "error": "Error", + "error_delete_face": "Error deleting face from asset", "error_loading_image": "Error loading image", "error_title": "Error - Something went wrong", "errors": { @@ -763,11 +770,13 @@ "get_help": "Get Help", "getting_started": "Getting Started", "go_back": "Go back", - "go_to_search": "Go to search", "go_to_folder": "Go to folder", + "go_to_search": "Go to search", "group_albums_by": "Group albums by...", + "group_country": "Group by country", "group_no": "No grouping", "group_owner": "Group by owner", + "group_places_by": "Group places by...", "group_year": "Group by year", "has_quota": "Has quota", "hi_user": "Hi {name} ({email})", @@ -800,6 +809,7 @@ "include_shared_albums": "Include shared albums", "include_shared_partner_assets": "Include shared partner assets", "individual_share": "Individual share", + "individual_shares": "Individual shares", "info": "Info", "interval": { "day_at_onepm": "Every day at 1pm", @@ -881,6 +891,7 @@ "month": "Month", "more": "More", "moved_to_trash": "Moved to trash", + "mute_memories": "Mute Memories", "my_albums": "My albums", "name": "Name", "name_or_nickname": "Name or nickname", @@ -976,6 +987,7 @@ "permanently_deleted_asset": "Permanently deleted asset", "permanently_deleted_assets_count": "Permanently deleted {count, plural, one {# asset} other {# assets}}", "person": "Person", + "person_birthdate": "Born on {date}", "person_hidden": "{name}{hidden, select, true { (hidden)} other {}}", "photo_shared_all_users": "Looks like you shared your photos with all users or you don't have any user to share with.", "photos": "Photos", @@ -985,6 +997,7 @@ "pick_a_location": "Pick a location", "place": "Place", "places": "Places", + "places_count": "{count, plural, one {{count, number} Place} other {{count, number} Places}}", "play": "Play", "play_memories": "Play memories", "play_motion_photo": "Play Motion Photo", @@ -1069,6 +1082,8 @@ "remove_url": "Remove URL", "remove_user": "Remove user", "removed_api_key": "Removed API Key: {name}", + "removed_memory": "Removed memory", + "removed_photo_from_memory": "Removed photo from memory", "removed_from_archive": "Removed from archive", "removed_from_favorites": "Removed from favorites", "removed_from_favorites_count": "{count, plural, other {Removed #}} from favorites", @@ -1103,11 +1118,14 @@ "say_something": "Say something", "scan_all_libraries": "Scan All Libraries", "scan_library": "Scan", + "rescan": "Rescan", "scan_settings": "Scan Settings", "scanning_for_album": "Scanning for album...", "search": "Search", "search_albums": "Search albums", "search_by_context": "Search by context", + "search_by_description": "Search by description", + "search_by_description_example": "Hiking day in Sapa", "search_by_filename": "Search by file name or extension", "search_by_filename_example": "i.e. IMG_1234.JPG or PNG", "search_camera_make": "Search camera make...", @@ -1127,10 +1145,12 @@ "search_timezone": "Search timezone...", "search_type": "Search type", "search_your_photos": "Search your photos", + "search_rating": "Search by rating...", "searching_locales": "Searching locales...", "second": "Second", "see_all_people": "See all people", "select_album_cover": "Select album cover", + "select": "Select", "select_all": "Select all", "select_all_duplicates": "Select all duplicates", "select_avatar_color": "Select avatar color", @@ -1152,8 +1172,8 @@ "server_version": "Server Version", "set": "Set", "set_as_album_cover": "Set as album cover", - "set_as_profile_picture": "Set as profile picture", "set_as_featured_photo": "Set as featured photo", + "set_as_profile_picture": "Set as profile picture", "set_date_of_birth": "Set date of birth", "set_profile_picture": "Set profile picture", "set_slideshow_to_fullscreen": "Set Slideshow to fullscreen", @@ -1167,6 +1187,7 @@ "shared_from_partner": "Photos from {partner}", "shared_link_options": "Shared link options", "shared_links": "Shared links", + "shared_links_description": "Share photos and videos with a link", "shared_photos_and_videos_count": "{assetCount, plural, other {# shared photos & videos.}}", "shared_with_partner": "Shared with {partner}", "sharing": "Sharing", @@ -1189,6 +1210,7 @@ "show_person_options": "Show person options", "show_progress_bar": "Show Progress Bar", "show_search_options": "Show search options", + "show_shared_links": "Show shared links", "show_slideshow_transition": "Show slideshow transition", "show_supporter_badge": "Supporter badge", "show_supporter_badge_description": "Show a supporter badge", @@ -1242,6 +1264,7 @@ "tag_created": "Created tag: {tag}", "tag_feature_description": "Browsing photos and videos grouped by logical tag topics", "tag_not_found_question": "Cannot find a tag? Create a new tag.", + "tag_people": "Tag People", "tag_updated": "Updated tag: {tag}", "tagged_assets": "Tagged {count, plural, one {# asset} other {# assets}}", "tags": "Tags", @@ -1276,6 +1299,7 @@ "unfavorite": "Unfavorite", "unhide_person": "Unhide person", "unknown": "Unknown", + "unknown_country": "Unknown Country", "unknown_year": "Unknown Year", "unlimited": "Unlimited", "unlink_motion_video": "Unlink motion video", @@ -1284,6 +1308,7 @@ "unnamed_album": "Unnamed Album", "unnamed_album_delete_confirmation": "Are you sure you want to delete this album?", "unnamed_share": "Unnamed Share", + "unmute_memories": "Unmute Memories", "unsaved_change": "Unsaved change", "unselect_all": "Unselect all", "unselect_all_duplicates": "Unselect all duplicates", @@ -1334,6 +1359,7 @@ "view_all": "View All", "view_all_users": "View all users", "view_in_timeline": "View in timeline", + "view_link": "View link", "view_links": "View links", "view_name": "View", "view_next_asset": "View next asset", diff --git a/i18n/es.json b/i18n/es.json index c619fbfeb8..4885a1d1b2 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -7,7 +7,7 @@ "actions": "Acciones", "active": "Activo", "activity": "Actividad", - "activity_changed": "La actividad está {enabled, select, true {activada} other {desactivada}}", + "activity_changed": "La actividad está {enabled, select, true {habilitada} other {deshabilitada}}", "add": "Agregar", "add_a_description": "Agregar descripción", "add_a_location": "Agregar ubicación", @@ -20,11 +20,11 @@ "add_partner": "Agregar compañero", "add_path": "Agregar carpeta", "add_photos": "Agregar fotos", - "add_to": "Agregar a...", + "add_to": "Agregar a…", "add_to_album": "Incluir en álbum", "add_to_shared_album": "Incluir en álbum compartido", "add_url": "Añadir URL", - "added_to_archive": "Archivado", + "added_to_archive": "Agregado al Archivado", "added_to_favorites": "Agregado a favoritos", "added_to_favorites_count": "Agregado {count, number} a favoritos", "admin": { @@ -41,6 +41,7 @@ "backup_settings": "Ajustes de respaldo", "backup_settings_description": "Administrar configuración de respaldo de base de datos", "check_all": "Verificar todo", + "cleanup": "Limpieza", "cleared_jobs": "Trabajos borrados para: {job}", "config_set_by_file": "La configuración está definida por un archivo de configuración", "confirm_delete_library": "¿Estás seguro de que quieres eliminar la biblioteca {library}?", @@ -96,7 +97,7 @@ "library_scanning_enable_description": "Activar el escaneo periódico de la biblioteca", "library_settings": "Biblioteca externa", "library_settings_description": "Administrar configuración biblioteca externa", - "library_tasks_description": "Realizar tareas de biblioteca", + "library_tasks_description": "Buscar archivos nuevos o modificados en bibliotecas externas", "library_watching_enable_description": "Vigilar las bibliotecas externas para detectar cambios en los archivos", "library_watching_settings": "Vigilancia de la biblioteca (EXPERIMENTAL)", "library_watching_settings_description": "Vigilar automaticamente en busca de archivos modificados", @@ -131,7 +132,7 @@ "machine_learning_smart_search_description": "Busque imágenes semánticamente utilizando incrustaciones CLIP (Contrastive Language-Image Pre-Training)", "machine_learning_smart_search_enabled": "Habilitar búsqueda inteligente", "machine_learning_smart_search_enabled_description": "Al desactivarlo las imágenes no se procesarán para usar la búsqueda inteligente.", - "machine_learning_url_description": "La URL del servidor de aprendizaje automático. Si se proporciona más de una URL se intentará acceder a cada servidor sucesivamente hasta que uno responda correctamente en el orden especificado.", + "machine_learning_url_description": "La URL del servidor de aprendizaje automático. Si se proporciona más de una URL se intentará acceder a cada servidor sucesivamente hasta que uno responda correctamente en el orden especificado. Los servidores que no respondan serán ignorados temporalmente hasta que vuelvan a estar en línea.", "manage_concurrency": "Ajustes de concurrencia", "manage_log_settings": "Administrar la configuración de los registros", "map_dark_style": "Estilo oscuro", @@ -147,6 +148,8 @@ "map_settings": "Mapa", "map_settings_description": "Administrar la configuración del mapa", "map_style_description": "Dirección URL a un tema de mapa (style.json)", + "memory_cleanup_job": "Limpieza de memoria", + "memory_generate_job": "Generación de memoria", "metadata_extraction_job": "Extracción de metadatos", "metadata_extraction_job_description": "Extraer información de metadatos de cada activo, como GPS, caras y resolución", "metadata_faces_import_setting": "Activar importación de caras", @@ -219,7 +222,7 @@ "reset_settings_to_default": "Restablecer la configuración predeterminada", "reset_settings_to_recent_saved": "Restablecer la configuración a la configuración guardada recientemente", "scanning_library": "Escaneando la biblioteca", - "search_jobs": "Buscar trabajo...", + "search_jobs": "Buscar trabajos…", "send_welcome_email": "Enviar correo de bienvenida", "server_external_domain_settings": "Dominio externo", "server_external_domain_settings_description": "Dominio para enlaces públicos compartidos, incluidos http(s)://", @@ -240,7 +243,7 @@ "storage_template_hash_verification_enabled_description": "Habilita la verificación de hash, no la desactive a menos que esté seguro de las implicaciones", "storage_template_migration": "Migración de plantillas de almacenamiento", "storage_template_migration_description": "Aplicar la {template} actual a los elementos subidos previamente", - "storage_template_migration_info": "Los cambios en las plantillas solo se aplican a los elementos nuevos. Para aplicarlos retroactivamente a los elementos subidos previamente ejecute la {job}.", + "storage_template_migration_info": "La plantilla de almacenamiento convertirá todas las extensiones a minúscula. Los cambios en las plantillas solo se aplican a los elementos nuevos. Para aplicarlos retroactivamente a los elementos subidos previamente ejecute la {job}.", "storage_template_migration_job": "Migración de la plantilla de almacenamiento", "storage_template_more_details": "Para obtener más detalles sobre esta función, consulte la Plantilla de almacenamiento y sus implicaciones", "storage_template_onboarding_description": "Cuando está habilitada, esta función organizará automáticamente los archivos según una plantilla definida por el usuario. Debido a problemas de estabilidad, la función se ha desactivado de forma predeterminada. Para obtener más información, consulte la documentación.", @@ -299,7 +302,7 @@ "transcoding_max_b_frames": "Maximos B-frames", "transcoding_max_b_frames_description": "Los valores más altos mejoran la eficiencia de la compresión, pero ralentizan la codificación. Puede que no sea compatible con la aceleración de hardware en dispositivos más antiguos. 0 desactiva los fotogramas B, mientras que -1 establece este valor automáticamente.", "transcoding_max_bitrate": "Máxima tasa de bits", - "transcoding_max_bitrate_description": "Establecer una tasa de bits máxima puede hacer que los tamaños de archivos sean más predecibles con un costo menor para la calidad. A 720p, los valores típicos son 2600k para VP9 o HEVC, o 4500k para H.264. Deshabilitado si se establece en 0.", + "transcoding_max_bitrate_description": "Establecer una tasa de bits máxima puede hacer que los tamaños de archivos sean más predecibles con un costo menor para la calidad. A 720p, los valores típicos son 2600 kbit/s para VP9 o HEVC, o 4500 kbit/s para H.264. Deshabilitado si se establece en 0.", "transcoding_max_keyframe_interval": "Intervalo máximo de fotogramas clave", "transcoding_max_keyframe_interval_description": "Establece la distancia máxima de fotograma entre fotogramas clave. Los valores más bajos empeoran la eficiencia de la compresión, pero mejoran los tiempos de búsqueda y pueden mejorar la calidad en escenas con movimientos rápidos. 0 establece este valor automáticamente.", "transcoding_optimal_description": "Vídeos con una resolución superior a la fijada o que no están en un formato aceptado", @@ -391,6 +394,7 @@ "allow_edits": "Permitir edición", "allow_public_user_to_download": "Permitir descargar al usuario público", "allow_public_user_to_upload": "Permitir cargar al usuario publico", + "alt_text_qr_code": "Código QR", "anti_clockwise": "En sentido antihorario", "api_key": "Clave API", "api_key_description": "Este valor sólo se mostrará una vez. Asegúrese de copiarlo antes de cerrar la ventana.", @@ -406,17 +410,17 @@ "are_these_the_same_person": "¿Son la misma persona?", "are_you_sure_to_do_this": "¿Estas seguro de que quieres hacer esto?", "asset_added_to_album": "Añadido al álbum", - "asset_adding_to_album": "Añadiendo al álbum...", + "asset_adding_to_album": "Añadiendo al álbum…", "asset_description_updated": "La descripción del elemento ha sido actualizada", "asset_filename_is_offline": "El archivo {filename} está offline", "asset_has_unassigned_faces": "El archivo no tiene rostros asignados", - "asset_hashing": "Hashing...", + "asset_hashing": "Hashing…", "asset_offline": "Archivos sin conexión", "asset_offline_description": "Este activo externo ya no se encuentra en el disco. Por favor, póngase en contacto con su administrador de Immich para obtener ayuda.", "asset_skipped": "Omitido", "asset_skipped_in_trash": "En la papelera", "asset_uploaded": "Subido", - "asset_uploading": "Subiendo...", + "asset_uploading": "Subiendo…", "assets": "elementos", "assets_added_count": "Añadido {count, plural, one {# asset} other {# assets}}", "assets_added_to_album_count": "Añadido {count, plural, one {# asset} other {# assets}} al álbum", @@ -438,7 +442,7 @@ "blurred_background": "Fondo borroso", "bugs_and_feature_requests": "Errores y solicitudes de funciones", "build": "Compilación", - "build_image": "Imagen", + "build_image": "Crear imagen", "bulk_delete_duplicates_confirmation": "¿Estás seguro de que deseas eliminar de forma masiva {count, plural, one {# elemento duplicado} other {# elementos duplicados}}? Esto mantendrá el activo más grande de cada grupo y eliminará permanentemente todos los demás duplicados. ¡Esta acción no se puede deshacer!", "bulk_keep_duplicates_confirmation": "¿Estas seguro de que desea mantener {count, plural, one {# duplicate asset} other {# duplicate assets}} archivos duplicados? Esto resolverá todos los grupos duplicados sin borrar nada.", "bulk_trash_duplicates_confirmation": "¿Estas seguro de que desea eliminar masivamente {count, plural, one {# duplicate asset} other {# duplicate assets}} archivos duplicados? Esto mantendrá el archivo más grande de cada grupo y eliminará todos los demás duplicados.", @@ -481,6 +485,7 @@ "comments_are_disabled": "Los comentarios están deshabilitados", "confirm": "Confirmar", "confirm_admin_password": "Confirmar Contraseña de Administrador", + "confirm_delete_face": "¿Estás seguro que deseas eliminar la cara de {name} del archivo?", "confirm_delete_shared_link": "¿Estás seguro de que deseas eliminar este enlace compartido?", "confirm_keep_this_delete_others": "Todos los demás activos de la pila se eliminarán excepto este activo. ¿Está seguro de que quiere continuar?", "confirm_password": "Confirmar contraseña", @@ -533,6 +538,7 @@ "delete_album": "Eliminar álbum", "delete_api_key_prompt": "¿Está seguro de que desea eliminar esta clave API?", "delete_duplicates_confirmation": "¿Está seguro de que desea eliminar permanentemente estos duplicados?", + "delete_face": "Eliminar cara", "delete_key": "Eliminar clave", "delete_library": "Eliminar biblioteca", "delete_link": "Eliminar enlace", @@ -600,6 +606,7 @@ "enabled": "Habilitado", "end_date": "Fecha final", "error": "Error", + "error_delete_face": "Error al eliminar la cara del archivo", "error_loading_image": "Error al cargar la imagen", "error_title": "Error: algo salió mal", "errors": { @@ -766,8 +773,10 @@ "go_to_folder": "Ir al directorio", "go_to_search": "Ir a búsqueda", "group_albums_by": "Agrupar albums por...", + "group_country": "Agrupar por país", "group_no": "Sin agrupación", "group_owner": "Agrupar por propietario", + "group_places_by": "Agrupar lugares por...", "group_year": "Agrupar por año", "has_quota": "Su cuota", "hi_user": "Hola {name} ({email})", @@ -800,6 +809,7 @@ "include_shared_albums": "Incluir álbumes compartidos", "include_shared_partner_assets": "Incluir archivos compartidos de invitados", "individual_share": "Compartir individualmente", + "individual_shares": "Acciones individuales", "info": "Información", "interval": { "day_at_onepm": "Todos los días a las 1pm", @@ -822,6 +832,7 @@ "latest_version": "Última versión", "latitude": "Latitud", "leave": "Abandonar", + "lens_model": "Modelo de objetivo", "let_others_respond": "Permitir que otros respondan", "level": "Nivel", "library": "Biblioteca", @@ -880,6 +891,7 @@ "month": "Mes", "more": "Mas", "moved_to_trash": "Movido a la papelera", + "mute_memories": "Silenciar Recuerdos", "my_albums": "Mis albums", "name": "Nombre", "name_or_nickname": "Nombre o apodo", @@ -975,6 +987,7 @@ "permanently_deleted_asset": "Archivo eliminado permanentemente", "permanently_deleted_assets_count": "Eliminado permanentemente {count, plural, one {# elemento} other {# elementos}}", "person": "Persona", + "person_birthdate": "Nacido el {date}", "person_hidden": "{name}{hidden, select, true { (oculto)} other {}}", "photo_shared_all_users": "Parece que compartiste tus fotos con todos los usuarios o no tienes ningún usuario con quien compartirlas.", "photos": "Fotos", @@ -984,6 +997,7 @@ "pick_a_location": "Elige una ubicación", "place": "Lugar", "places": "Lugares", + "places_count": "{count, plural, one {{count, number} Lugar} other {{count, number} Lugares}}", "play": "Reproducir", "play_memories": "Reproducir recuerdos", "play_motion_photo": "Reproducir foto en movimiento", @@ -1071,6 +1085,8 @@ "removed_from_archive": "Eliminado del archivo", "removed_from_favorites": "Eliminado de favoritos", "removed_from_favorites_count": "{count, plural, other {Eliminados #}} de favoritos", + "removed_memory": "Memoria eliminada", + "removed_photo_from_memory": "Se ha eliminado la foto de la memoria", "removed_tagged_assets": "Etiqueta eliminada de {count, plural, one {# activo} other {# activos}}", "rename": "Renombrar", "repair": "Reparar", @@ -1079,6 +1095,7 @@ "repository": "Repositorio", "require_password": "Contraseña requerida", "require_user_to_change_password_on_first_login": "Requerir que el usuario cambie la contraseña en el primer inicio de sesión", + "rescan": "Volver a escanear", "reset": "Reiniciar", "reset_password": "Restablecer la contraseña", "reset_people_visibility": "Restablecer la visibilidad de las personas", @@ -1107,18 +1124,22 @@ "search": "Buscar", "search_albums": "Buscar álbums", "search_by_context": "Buscar por contexto", + "search_by_description": "Buscar por descripción", + "search_by_description_example": "Día de senderismo en Sapa", "search_by_filename": "Buscar por nombre de archivo o extensión", "search_by_filename_example": "es decir IMG_1234.JPG o PNG", "search_camera_make": "Buscar fabricante de cámara...", "search_camera_model": "Buscar modelo de cámara...", "search_city": "Buscar ciudad...", "search_country": "Buscar país...", + "search_for": "Buscar", "search_for_existing_person": "Buscar persona existente", "search_no_people": "Ninguna persona", "search_no_people_named": "Ninguna persona llamada \"{name}\"", "search_options": "Opciones de búsqueda", "search_people": "Buscar personas", "search_places": "Buscar lugar", + "search_rating": "Buscar por calificación...", "search_settings": "Ajustes de la búsqueda", "search_state": "Buscar región/estado...", "search_tags": "Buscando etiquetas...", @@ -1139,7 +1160,7 @@ "select_library_owner": "Seleccionar propietario de la biblioteca", "select_new_face": "Seleccionar nueva cara", "select_photos": "Seleccionar Fotos", - "select_trash_all": "Descartar todo", + "select_trash_all": "Seleccionar eliminar todo", "selected": "Seleccionado", "selected_count": "{count, plural, one {# seleccionado} other {# seleccionados}}", "send_message": "Enviar mensaje", @@ -1165,6 +1186,7 @@ "shared_from_partner": "Fotos de {partner}", "shared_link_options": "Opciones de enlaces compartidos", "shared_links": "Enlaces compartidos", + "shared_links_description": "Comparte fotos y vídeos con un enlace", "shared_photos_and_videos_count": "{assetCount, plural, other {# Fotos y vídeos compartidos.}}", "shared_with_partner": "Compartido con {partner}", "sharing": "Compartido", @@ -1187,6 +1209,7 @@ "show_person_options": "Mostrar opciones de la persona", "show_progress_bar": "Mostrar barra de progreso", "show_search_options": "Mostrar opciones de búsqueda", + "show_shared_links": "Mostrar enlaces compartidos", "show_slideshow_transition": "Mostrar la transición de las diapositivas", "show_supporter_badge": "Insignia de colaborador", "show_supporter_badge_description": "Mostrar una insignia de colaborador", @@ -1240,6 +1263,7 @@ "tag_created": "Etiqueta creada: {tag}", "tag_feature_description": "Explore fotos y videos agrupados por temas de etiquetas lógicas", "tag_not_found_question": "¿No encuentra una etiqueta? Crea una nueva etiqueta.", + "tag_people": "Etiquetar personas", "tag_updated": "Etiqueta actualizada: {tag}", "tagged_assets": "Etiquetado(s) {count, plural, one {# activo} other {# activos}}", "tags": "Etiquetas", @@ -1274,11 +1298,13 @@ "unfavorite": "Retirar favorito", "unhide_person": "Mostrar persona", "unknown": "Desconocido", + "unknown_country": "País desconocido", "unknown_year": "Año desconocido", "unlimited": "Ilimitado", "unlink_motion_video": "Desvincular vídeo en movimiento", "unlink_oauth": "Desvincular OAuth", "unlinked_oauth_account": "Cuenta OAuth desconectada", + "unmute_memories": "Habilitar sonido recuerdos", "unnamed_album": "Album sin nombre", "unnamed_album_delete_confirmation": "¿Seguro que quieres borrar este álbum?", "unnamed_share": "Compartido sin nombre", @@ -1332,6 +1358,7 @@ "view_all": "Ver todas", "view_all_users": "Mostrar todos los usuarios", "view_in_timeline": "Mostrar en la línea de tiempo", + "view_link": "Ver enlace", "view_links": "Mostrar enlaces", "view_name": "Ver", "view_next_asset": "Mostrar siguiente elemento", @@ -1348,4 +1375,4 @@ "yes": "Sí", "you_dont_have_any_shared_links": "No tienes ningún enlace compartido", "zoom_image": "Acercar Imagen" -} +} \ No newline at end of file diff --git a/i18n/et.json b/i18n/et.json index ab17fad19f..0fa6ba0247 100644 --- a/i18n/et.json +++ b/i18n/et.json @@ -20,7 +20,7 @@ "add_partner": "Lisa partner", "add_path": "Lisa tee", "add_photos": "Lisa fotosid", - "add_to": "Lisa kohta...", + "add_to": "Lisa kohta…", "add_to_album": "Lisa albumisse", "add_to_shared_album": "Lisa jagatud albumisse", "add_url": "Lisa URL", @@ -41,6 +41,7 @@ "backup_settings": "Varundamise seaded", "backup_settings_description": "Halda andmebaasi varundamise seadeid", "check_all": "Märgi kõik", + "cleanup": "Koristus", "cleared_jobs": "Tööted eemaldatud: {job}", "config_set_by_file": "Konfiguratsioon on määratud konfifaili abil", "confirm_delete_library": "Kas oled kindel, et soovid kustutada {library} kogu?", @@ -96,7 +97,7 @@ "library_scanning_enable_description": "Luba kogu perioodiline skaneerimine", "library_settings": "Väline kogu", "library_settings_description": "Halda välise kogu seadeid", - "library_tasks_description": "Soorita kogu toiminguid", + "library_tasks_description": "Otsi välistest kogudest uusi ja muutunud üksuseid", "library_watching_enable_description": "Jälgi välises kogus failide muudatusi", "library_watching_settings": "Kogu jälgimine (EKSPERIMENTAALNE)", "library_watching_settings_description": "Jälgi automaatselt muutunud faile", @@ -131,7 +132,7 @@ "machine_learning_smart_search_description": "Otsi pilte semantiliselt CLIP-manuste abil", "machine_learning_smart_search_enabled": "Luba nutiotsing", "machine_learning_smart_search_enabled_description": "Kui keelatud, siis ei kodeerita pilte nutiotsingu jaoks.", - "machine_learning_url_description": "Masinõppe serveri URL. Kui ette on antud rohkem kui üks URL, proovitakse neid järjest ükshaaval, kuni üks edukalt vastab.", + "machine_learning_url_description": "Masinõppe serveri URL. Kui ette on antud rohkem kui üks URL, proovitakse neid järjest ükshaaval, kuni üks edukalt vastab. Servereid, mis ei vasta, ignoreeritakse ajutiselt, kuni ühendus taastub.", "manage_concurrency": "Halda samaaegsust", "manage_log_settings": "Halda logi seadeid", "map_dark_style": "Tume stiil", @@ -147,6 +148,8 @@ "map_settings": "Kaart", "map_settings_description": "Halda kaardi seadeid", "map_style_description": "Kaarditeema style.json URL", + "memory_cleanup_job": "Mälestuste korrastamine", + "memory_generate_job": "Mälestuste genereerimine", "metadata_extraction_job": "Metaandmete eraldamine", "metadata_extraction_job_description": "Eralda igast üksusest metaandmed, nagu GPS-koordinaadid, näod ja resolutsioon", "metadata_faces_import_setting": "Luba nägude import", @@ -219,7 +222,7 @@ "reset_settings_to_default": "Lähtesta seaded", "reset_settings_to_recent_saved": "Taasta hiljuti salvestatud seaded", "scanning_library": "Kogu skaneerimine", - "search_jobs": "Otsi töödet...", + "search_jobs": "Otsi töödet…", "send_welcome_email": "Saada tervituskiri", "server_external_domain_settings": "Väline domeen", "server_external_domain_settings_description": "Domeen avalikult jagatud linkide jaoks, k.a. http(s)://", @@ -240,7 +243,7 @@ "storage_template_hash_verification_enabled_description": "Lülitab sisse räsi kontrolli; ära lülita seda välja, kui sa ei ole tagajärgedest teadlik", "storage_template_migration": "Talletusmalli migreerimine", "storage_template_migration_description": "Rakenda praegune {template} varem üleslaaditud üksustele", - "storage_template_migration_info": "Malli muudatused rakenduvad ainult uutele üksustele. Et rakendada malli tagasiulatuvalt varem üleslaaditud üksustele, käivita {job}.", + "storage_template_migration_info": "Talletusmall teeb kõik faililaiendid väiketähtedeks. Malli muudatused rakenduvad ainult uutele üksustele. Et rakendada malli tagasiulatuvalt varem üleslaaditud üksustele, käivita {job}.", "storage_template_migration_job": "Talletusmallide migreerimise tööde", "storage_template_more_details": "Et selle funktsiooni kohta rohkem teada saada, loe talletusmallide ja nende tagajärgede kohta", "storage_template_onboarding_description": "Kui sisse lülitatud, võimaldab see faile kasutaja määratud malli alusel automaatselt organiseerida. Stabiilsusprobleemide tõttu on see funktsioon vaikimisi välja lülitatud. Rohkem infot leiad dokumentatsioonist.", @@ -299,7 +302,7 @@ "transcoding_max_b_frames": "Maksimaalne B-kaadrite arv", "transcoding_max_b_frames_description": "Kõrgemad väärtused parandavad pakkimise efektiivsust, aga aeglustavad kodeerimist. See valik ei pruugi olla ühilduv riistvaralise kiirendusega vanematel seadmetel. 0 lülitab B-kaadrid välja, -1 määrab väärtuse automaatselt.", "transcoding_max_bitrate": "Maksimaalne bitisagedus", - "transcoding_max_bitrate_description": "Maksimaalse bitisageduse määramine teeb failisuurused ennustatavamaks, väikese kvaliteedikao hinnaga. 720p resolutsiooni puhul on tüüpilised väärtused 2600k (VP9 ja HEVC) või 4500k (H.264). Väärtus 0 eemaldab piirangu.", + "transcoding_max_bitrate_description": "Maksimaalse bitisageduse määramine teeb failisuurused ennustatavamaks, väikese kvaliteedikao hinnaga. 720p resolutsiooni puhul on tüüpilised väärtused 2600 kbit/s (VP9 ja HEVC) või 4500 kbit/s (H.264). Väärtus 0 eemaldab piirangu.", "transcoding_max_keyframe_interval": "Maksimaalne võtmekaadri intervall", "transcoding_max_keyframe_interval_description": "Määrab maksimaalse kauguse võtmekaadrite vahel. Madalamad väärtused vähendavad pakkimise efektiivsust, aga parandavad otsimiskiirust ning võivad tõsta kiire liikumisega stseenide kvaliteeti. 0 määrab väärtuse automaatselt.", "transcoding_optimal_description": "Kõrgema kui lubatud resolutsiooniga või mittelubatud formaadis videod", @@ -391,6 +394,7 @@ "allow_edits": "Luba muutmine", "allow_public_user_to_download": "Luba avalikul kasutajal alla laadida", "allow_public_user_to_upload": "Luba avalikul kasutajal üles laadida", + "alt_text_qr_code": "QR kood", "anti_clockwise": "Vastupäeva", "api_key": "API võti", "api_key_description": "Seda väärtust kuvatakse ainult üks kord. Kopeeri see enne akna sulgemist.", @@ -406,17 +410,17 @@ "are_these_the_same_person": "Kas need on sama isik?", "are_you_sure_to_do_this": "Kas oled kindel, et soovid seda teha?", "asset_added_to_album": "Lisatud albumisse", - "asset_adding_to_album": "Albumisse lisamine...", + "asset_adding_to_album": "Albumisse lisamine…", "asset_description_updated": "Üksuse kirjeldus on muudetud", "asset_filename_is_offline": "Üksus {filename} ei ole kättesaadav", "asset_has_unassigned_faces": "Üksusel on seostamata nägusid", - "asset_hashing": "Räsimine...", + "asset_hashing": "Räsimine…", "asset_offline": "Üksus pole kättesaadav", "asset_offline_description": "Seda välise kogu üksust ei leitud kettalt. Abi saamiseks palun võta ühendust oma Immich'i administraatoriga.", "asset_skipped": "Vahele jäetud", "asset_skipped_in_trash": "Prügikastis", "asset_uploaded": "Üleslaaditud", - "asset_uploading": "Üleslaadimine...", + "asset_uploading": "Üleslaadimine…", "assets": "Üksused", "assets_added_count": "{count, plural, one {# üksus} other {# üksust}} lisatud", "assets_added_to_album_count": "{count, plural, one {# üksus} other {# üksust}} albumisse lisatud", @@ -431,7 +435,7 @@ "assets_were_part_of_album_count": "{count, plural, one {Üksus oli} other {Üksused olid}} juba osa albumist", "authorized_devices": "Autoriseeritud seadmed", "back": "Tagasi", - "back_close_deselect": "Tagasi, sulge, või tühista valik", + "back_close_deselect": "Tagasi, sulge või tühista valik", "backward": "Tagasi", "birthdate_saved": "Sünnikuupäev salvestatud", "birthdate_set_description": "Sünnikuupäeva kasutatakse isiku vanuse arvutamiseks foto tegemise hetkel.", @@ -481,6 +485,7 @@ "comments_are_disabled": "Kommentaarid on keelatud", "confirm": "Kinnita", "confirm_admin_password": "Kinnita administraatori parool", + "confirm_delete_face": "Kas oled kindel, et soovid isiku {name} näo üksuselt kustutada?", "confirm_delete_shared_link": "Kas oled kindel, et soovid selle jagatud lingi kustutada?", "confirm_keep_this_delete_others": "Kõik muud üksused selles virnas kustutatakse. Kas oled kindel, et soovid jätkata?", "confirm_password": "Kinnita parool", @@ -533,6 +538,7 @@ "delete_album": "Kustuta album", "delete_api_key_prompt": "Kas oled kindel, et soovid selle API võtme kustutada?", "delete_duplicates_confirmation": "Kas oled kindel, et soovid need duplikaadid jäädavalt kustutada?", + "delete_face": "Kustuta nägu", "delete_key": "Kustuta võti", "delete_library": "Kustuta kogu", "delete_link": "Kustuta link", @@ -600,6 +606,7 @@ "enabled": "Lubatud", "end_date": "Lõppkuupäev", "error": "Viga", + "error_delete_face": "Viga näo kustutamisel", "error_loading_image": "Viga pildi laadimisel", "error_title": "Viga - midagi läks valesti", "errors": { @@ -718,6 +725,7 @@ "unable_to_submit_job": "Tööte edastamine ebaõnnestus", "unable_to_trash_asset": "Üksuse prügikasti liigutamine ebaõnnestus", "unable_to_unlink_account": "Konto lahtiühendamine ebaõnnestus", + "unable_to_unlink_motion_video": "Liikuva video linkimise tühistamine ebaõnnestus", "unable_to_update_album_cover": "Albumi kaanepildi muutmine ebaõnnestus", "unable_to_update_album_info": "Albumi info muutmine ebaõnnestus", "unable_to_update_library": "Kogu uuendamine ebaõnnestus", @@ -734,6 +742,7 @@ "expired": "Aegunud", "expires_date": "Aegub {date}", "explore": "Avasta", + "explorer": "Brauser", "export": "Ekspordi", "export_as_json": "Ekspordi JSON-formaati", "extension": "Laiend", @@ -742,6 +751,7 @@ "face_unassigned": "Seostamata", "failed_to_load_assets": "Üksuste laadimine ebaõnnestus", "favorite": "Lemmik", + "favorite_or_unfavorite_photo": "Lisa foto lemmikutesse või eemalda lemmikutest", "favorites": "Lemmikud", "feature_photo_updated": "Esiletõstetud foto muudetud", "features": "Funktsioonid", @@ -763,8 +773,10 @@ "go_to_folder": "Mine kausta", "go_to_search": "Otsingusse", "group_albums_by": "Grupeeri albumid...", + "group_country": "Grupeeri riigi kaupa", "group_no": "Ära grupeeri", "group_owner": "Grupeeri omaniku kaupa", + "group_places_by": "Grupeeri kohad...", "group_year": "Grupeeri aasta kaupa", "has_quota": "On kvoot", "hi_user": "Tere {name} ({email})", @@ -797,6 +809,7 @@ "include_shared_albums": "Kaasa jagatud albumid", "include_shared_partner_assets": "Kaasa partneri jagatud üksused", "individual_share": "Jagatud üksus", + "individual_shares": "Jagatud üksused", "info": "Info", "interval": { "day_at_onepm": "Iga päev kell 13", @@ -819,12 +832,14 @@ "latest_version": "Uusim versioon", "latitude": "Laiuskraad", "leave": "Lahku", + "lens_model": "Läätse mudel", "let_others_respond": "Luba teistel vastata", "level": "Tase", "library": "Kogu", "library_options": "Kogu seaded", "light": "Hele", "like_deleted": "Meeldimine kustutatud", + "link_motion_video": "Lingi liikuv video", "link_options": "Lingi valikud", "link_to_oauth": "Ühenda OAuth", "linked_oauth_account": "OAuth konto ühendatud", @@ -861,6 +876,7 @@ "memories": "Mälestused", "memories_setting_description": "Halda, mida sa oma mälestustes näed", "memory": "Mälestus", + "memory_lane_title": "Mälestus {title}", "menu": "Menüü", "merge": "Ühenda", "merge_people": "Ühenda isikud", @@ -875,6 +891,7 @@ "month": "Kuu", "more": "Rohkem", "moved_to_trash": "Liigutatud prügikasti", + "mute_memories": "Vaigista mälestused", "my_albums": "Minu albumid", "name": "Nimi", "name_or_nickname": "Nimi või hüüdnimi", @@ -970,6 +987,7 @@ "permanently_deleted_asset": "Üksus jäädavalt kustutatud", "permanently_deleted_assets_count": "{count, plural, one {# üksus} other {# üksust}} jäädavalt kustutatud", "person": "Isik", + "person_birthdate": "Sündinud {date}", "person_hidden": "{name}{hidden, select, true { (peidetud)} other {}}", "photo_shared_all_users": "Paistab, et oled oma fotosid kõigi kasutajatega jaganud, või pole ühtegi kasutajat, kellega jagada.", "photos": "Fotod", @@ -979,6 +997,7 @@ "pick_a_location": "Vali asukoht", "place": "Asukoht", "places": "Kohad", + "places_count": "{count, plural, one {{count, number} koht} other {{count, number} kohta}}", "play": "Esita", "play_memories": "Esita mälestused", "play_motion_photo": "Esita liikuv foto", @@ -994,6 +1013,7 @@ "profile_image_of_user": "Kasutaja {user} profiilipilt", "profile_picture_set": "Profiilipilt määratud.", "public_album": "Avalik album", + "public_share": "Avalik jagamine", "purchase_account_info": "Toetaja", "purchase_activated_subtitle": "Aitäh, et toetad Immich'it ja avatud lähtekoodiga tarkvara", "purchase_activated_time": "Aktiveeritud {date, date}", @@ -1032,6 +1052,7 @@ "rating_description": "Kuva infopaneelis EXIF hinnangut", "reaction_options": "Reaktsiooni valikud", "read_changelog": "Vaata muudatuste ülevaadet", + "reassign": "Määra uuesti", "reassigned_assets_to_existing_person": "{count, plural, one {# üksus} other {# üksust}} seostatud {name, select, null {olemasoleva isikuga} other {isikuga {name}}}", "reassigned_assets_to_new_person": "{count, plural, one {# üksus} other {# üksust}} seostatud uue isikuga", "reassing_hint": "Seosta valitud üksused olemasoleva isikuga", @@ -1064,13 +1085,17 @@ "removed_from_archive": "Arhiivist eemaldatud", "removed_from_favorites": "Lemmikutest eemaldatud", "removed_from_favorites_count": "{count, plural, other {# eemaldatud}} lemmikutest", + "removed_memory": "Mäletus eemaldatud", + "removed_photo_from_memory": "Foto mälestustest eemaldatud", "removed_tagged_assets": "Silt eemaldatud {count, plural, one {# üksuselt} other {# üksuselt}}", "rename": "Nimeta ümber", + "repair": "Parandus", "repair_no_results_message": "Mittejälgitavad ja puuduvad failid kuvatakse siin", "replace_with_upload": "Asenda üleslaadimisega", "repository": "Koodihoidla", "require_password": "Nõua parooli", "require_user_to_change_password_on_first_login": "Nõua kasutajalt esmakordsel sisenemisel parooli muutmist", + "rescan": "Skaneeri uuesti", "reset": "Lähtesta", "reset_password": "Lähtesta parool", "reset_people_visibility": "Lähtesta isikute nähtavus", @@ -1099,18 +1124,22 @@ "search": "Otsi", "search_albums": "Otsi albumeid", "search_by_context": "Otsi konteksti alusel", + "search_by_description": "Otsi kirjelduse alusel", + "search_by_description_example": "Matkapäev Sapas", "search_by_filename": "Otsi failinime või -laiendi järgi", "search_by_filename_example": "st. IMG_1234.JPG või PNG", "search_camera_make": "Otsi kaamera marki...", "search_camera_model": "Otsi kaamera mudelit...", "search_city": "Otsi linna...", "search_country": "Otsi riiki...", + "search_for": "Otsi", "search_for_existing_person": "Otsi olemasolevat isikut", "search_no_people": "Isikuid ei ole", "search_no_people_named": "Ei ole isikuid nimega \"{name}\"", "search_options": "Otsingu valikud", "search_people": "Otsi inimesi", "search_places": "Otsi kohti", + "search_rating": "Otsi hinnangu järgi...", "search_settings": "Otsi seadeid", "search_state": "Otsi osariiki...", "search_tags": "Otsi silte...", @@ -1127,9 +1156,11 @@ "select_face": "Vali nägu", "select_featured_photo": "Vali esiletõstetud foto", "select_from_computer": "Vali arvutist", + "select_keep_all": "Vali jäta kõik alles", "select_library_owner": "Vali kogu omanik", "select_new_face": "Vali uus nägu", "select_photos": "Vali fotod", + "select_trash_all": "Vali kõik prügikasti", "selected": "Valitud", "selected_count": "{count, plural, other {# valitud}}", "send_message": "Saada sõnum", @@ -1155,6 +1186,7 @@ "shared_from_partner": "Fotod partnerilt {partner}", "shared_link_options": "Jagatud lingi valikud", "shared_links": "Jagatud lingid", + "shared_links_description": "Jaga fotosid ja videosid lingiga", "shared_photos_and_videos_count": "{assetCount, plural, other {# jagatud fotot ja videot.}}", "shared_with_partner": "Jagatud partneriga {partner}", "sharing": "Jagamine", @@ -1177,6 +1209,7 @@ "show_person_options": "Näita isiku valikuid", "show_progress_bar": "Kuva edenemisriba", "show_search_options": "Kuva otsingu valikud", + "show_shared_links": "Näita jagatud linke", "show_slideshow_transition": "Kuva slaidiesitluse üleminekud", "show_supporter_badge": "Toetaja märk", "show_supporter_badge_description": "Kuva toetaja märki", @@ -1210,13 +1243,14 @@ "start_date": "Alguskuupäev", "state": "Osariik", "status": "Staatus", - "stop_motion_photo": "Peata liikuv pilt", + "stop_motion_photo": "Peata liikuv foto", "stop_photo_sharing": "Lõpeta oma fotode jagamine?", "stop_photo_sharing_description": "{partner} ei pääse rohkem su fotodele ligi.", "stop_sharing_photos_with_user": "Lõpeta oma fotode selle kasutajaga jagamine", "storage": "Talletusruum", "storage_label": "Talletussilt", "storage_usage": "{used}/{available} kasutatud", + "submit": "Saada", "suggestions": "Soovitused", "sunrise_on_the_beach": "Päikesetõus rannal", "support": "Tugi", @@ -1229,6 +1263,7 @@ "tag_created": "Lisatud silt: {tag}", "tag_feature_description": "Fotode ja videote lehitsemine siltide kaupa grupeeritult", "tag_not_found_question": "Ei leia silti? Lisa uus silt.", + "tag_people": "Sildista inimesi", "tag_updated": "Muudetud silt: {tag}", "tagged_assets": "{count, plural, one {# üksus} other {# üksust}} sildistatud", "tags": "Sildid", @@ -1245,6 +1280,7 @@ "to_change_password": "Muuda parool", "to_favorite": "Lemmik", "to_login": "Logi sisse", + "to_parent": "Tase üles", "to_trash": "Prügikasti", "toggle_settings": "Kuva/peida seaded", "toggle_theme": "Lülita tume teema", @@ -1262,13 +1298,19 @@ "unfavorite": "Eemalda lemmikutest", "unhide_person": "Ära peida isikut", "unknown": "Teadmata", + "unknown_country": "Tundmatu riik", "unknown_year": "Teadmata aasta", "unlimited": "Piiramatu", + "unlink_motion_video": "Tühista liikuva video linkimine", "unlink_oauth": "Eemalda OAuth ühendus", "unlinked_oauth_account": "OAuth ühendus eemaldatud", + "unmute_memories": "Tühista mälestuste vaigistamine", "unnamed_album": "Nimetu album", "unnamed_album_delete_confirmation": "Kas oled kindel, et soovid selle albumi kustutada?", + "unnamed_share": "Nimetu jagamine", "unsaved_change": "Salvestamata muudatus", + "unselect_all": "Ära vali ühtegi", + "unselect_all_duplicates": "Ära vali duplikaate", "unstack": "Eralda", "unstacked_assets_count": "{count, plural, one {# üksus} other {# üksust}} eraldatud", "untracked_files": "Mittejälgitavad failid", @@ -1316,6 +1358,7 @@ "view_all": "Vaata kõiki", "view_all_users": "Vaata kõiki kasutajaid", "view_in_timeline": "Vaata ajajoonel", + "view_link": "Vaata linki", "view_links": "Vaata linke", "view_name": "Vaade", "view_next_asset": "Vaata järgmist üksust", @@ -1332,4 +1375,4 @@ "yes": "Jah", "you_dont_have_any_shared_links": "Sul pole ühtegi jagatud linki", "zoom_image": "Suumi pilti" -} +} \ No newline at end of file diff --git a/i18n/fa.json b/i18n/fa.json index 1e40996f15..ca9b75c5e4 100644 --- a/i18n/fa.json +++ b/i18n/fa.json @@ -19,7 +19,7 @@ "add_partner": "افزودن شریک", "add_path": "افزودن مسیر", "add_photos": "افزودن عکس ها", - "add_to": "افزودن به ...", + "add_to": "افزودن به …", "add_to_album": "افزودن به آلبوم", "add_to_shared_album": "افزودن به آلبوم اشتراکی", "added_to_archive": "به آرشیو اضافه شد", @@ -254,7 +254,7 @@ "transcoding_max_b_frames": "بیشترین B-frames", "transcoding_max_b_frames_description": "مقادیر بالاتر کارایی فشرده سازی را بهبود می‌بخشند، اما کدگذاری را کند می‌کنند. ممکن است با شتاب دهی سخت‌افزاری در دستگاه‌های قدیمی سازگار نباشد. مقدار( 0 ) B-frames را غیرفعال می‌کند، در حالی که مقدار ( 1 ) این مقدار را به صورت خودکار تنظیم می‌کند.", "transcoding_max_bitrate": "بیشترین بیت ریت", - "transcoding_max_bitrate_description": "تنظیم حداکثر بیت‌ریت می‌تواند اندازه فایل‌ها را در حدی قابل پیش‌بینی‌تر کند، هرچند که هزینه کمی برای کیفیت دارد. در وضوح 720p، مقادیر معمول 2600k برای VP9 یا HEVC و 4500k برای H.264 است. اگر به 0 تنظیم شود، غیرفعال می‌شود.", + "transcoding_max_bitrate_description": "تنظیم حداکثر بیت‌ریت می‌تواند اندازه فایل‌ها را در حدی قابل پیش‌بینی‌تر کند، هرچند که هزینه کمی برای کیفیت دارد. در وضوح 720p، مقادیر معمول 2600 kbit/s برای VP9 یا HEVC و 4500 kbit/s برای H.264 است. اگر به 0 تنظیم شود، غیرفعال می‌شود.", "transcoding_max_keyframe_interval": "حداکثر فاصله کلید فریم", "transcoding_max_keyframe_interval_description": "حداکثر فاصله فریم بین کلیدفریم‌ها را تنظیم می‌کند. مقادیر پایین‌تر کارایی فشرده‌سازی را کاهش می‌دهند، اما زمان جستجو را بهبود می‌بخشند و ممکن است کیفیت را در صحنه‌های با حرکت سریع بهبود دهند. مقدار 0 این مقدار را به‌طور خودکار تنظیم می‌کند.", "transcoding_optimal_description": "ویدیوهایی که از رزولوشن هدف بالاتر هستند یا در قالب پذیرفته شده نیستند", @@ -312,157 +312,157 @@ "admin_password": "رمز عبور مدیر", "administration": "مدیریت", "advanced": "پیشرفته", - "album_added": "", + "album_added": "آلبوم اضافه شد", "album_added_notification_setting_description": "", - "album_cover_updated": "", - "album_info_updated": "", - "album_name": "", - "album_options": "", - "album_updated": "", + "album_cover_updated": "جلد آلبوم به‌روزرسانی شد", + "album_info_updated": "اطلاعات آلبوم به‌روزرسانی شد", + "album_name": "نام آلبوم", + "album_options": "گزینه‌های آلبوم", + "album_updated": "آلبوم به‌روزرسانی شد", "album_updated_setting_description": "", - "albums": "", + "albums": "آلبوم‌ها", "albums_count": "", - "all": "", - "all_people": "", - "allow_dark_mode": "", - "allow_edits": "", - "api_key": "", - "api_keys": "", - "app_settings": "", - "appears_in": "", - "archive": "", + "all": "همه", + "all_people": "همه افراد", + "allow_dark_mode": "اجازه دادن به حالت تاریک", + "allow_edits": "اجازه ویرایش", + "api_key": "کلید API", + "api_keys": "کلیدهای API", + "app_settings": "تنظیمات برنامه", + "appears_in": "ظاهر می‌شود در", + "archive": "بایگانی", "archive_or_unarchive_photo": "", - "archive_size": "", + "archive_size": "اندازه بایگانی", "archive_size_description": "", - "asset_offline": "", - "assets": "", - "authorized_devices": "", - "back": "", - "backward": "", - "blurred_background": "", + "asset_offline": "محتوا آفلاین", + "assets": "محتواها", + "authorized_devices": "دستگاه‌های مجاز", + "back": "بازگشت", + "backward": "عقب", + "blurred_background": "پس‌زمینه محو", "bulk_delete_duplicates_confirmation": "", "bulk_keep_duplicates_confirmation": "", "bulk_trash_duplicates_confirmation": "", - "camera": "", - "camera_brand": "", - "camera_model": "", - "cancel": "", - "cancel_search": "", - "cannot_merge_people": "", - "cannot_update_the_description": "", - "change_date": "", - "change_expiration_time": "", - "change_location": "", - "change_name": "", - "change_name_successfully": "", - "change_password": "", - "change_your_password": "", + "camera": "دوربین", + "camera_brand": "برند دوربین", + "camera_model": "مدل دوربین", + "cancel": "لغو", + "cancel_search": "لغو جستجو", + "cannot_merge_people": "نمی‌توان افراد را ادغام کرد", + "cannot_update_the_description": "نمی‌توان توضیحات را به‌روزرسانی کرد", + "change_date": "تغییر تاریخ", + "change_expiration_time": "تغییر زمان انقضا", + "change_location": "تغییر مکان", + "change_name": "تغییر نام", + "change_name_successfully": "نام با موفقیت تغییر یافت", + "change_password": "تغییر رمز عبور", + "change_your_password": "رمز عبور خود را تغییر دهید", "changed_visibility_successfully": "", - "check_all": "", - "check_logs": "", + "check_all": "انتخاب همه", + "check_logs": "بررسی لاگ‌ها", "choose_matching_people_to_merge": "", - "city": "", - "clear": "", - "clear_all": "", - "clear_message": "", - "clear_value": "", - "close": "", - "collapse_all": "", - "color_theme": "", - "comment_options": "", - "comments_are_disabled": "", - "confirm": "", - "confirm_admin_password": "", + "city": "شهر", + "clear": "پاک کردن", + "clear_all": "پاک کردن همه", + "clear_message": "پاک کردن پیام", + "clear_value": "پاک کردن مقدار", + "close": "بستن", + "collapse_all": "جمع کردن همه", + "color_theme": "تم رنگ", + "comment_options": "گزینه‌های نظر", + "comments_are_disabled": "نظرات غیرفعال هستند", + "confirm": "تأیید", + "confirm_admin_password": "تأیید رمز عبور مدیر", "confirm_delete_shared_link": "", - "confirm_password": "", - "contain": "", - "context": "", - "continue": "", - "copied_image_to_clipboard": "", - "copied_to_clipboard": "", - "copy_error": "", - "copy_file_path": "", - "copy_image": "", - "copy_link": "", - "copy_link_to_clipboard": "", - "copy_password": "", - "copy_to_clipboard": "", - "country": "", - "cover": "", - "covers": "", - "create": "", - "create_album": "", - "create_library": "", - "create_link": "", - "create_link_to_share": "", - "create_new_person": "", - "create_new_user": "", - "create_user": "", - "created": "", - "current_device": "", + "confirm_password": "تأیید رمز عبور", + "contain": "شامل", + "context": "زمینه", + "continue": "ادامه", + "copied_image_to_clipboard": "تصویر به کلیپ‌بورد کپی شد.", + "copied_to_clipboard": "به کلیپ‌بورد کپی شد!", + "copy_error": "خطا در کپی", + "copy_file_path": "کپی مسیر فایل", + "copy_image": "کپی تصویر", + "copy_link": "کپی لینک", + "copy_link_to_clipboard": "کپی لینک به کلیپ‌بورد", + "copy_password": "کپی رمز عبور", + "copy_to_clipboard": "کپی به کلیپ‌بورد", + "country": "کشور", + "cover": "جلد", + "covers": "جلدها", + "create": "ایجاد", + "create_album": "ایجاد آلبوم", + "create_library": "ایجاد کتابخانه", + "create_link": "ایجاد لینک", + "create_link_to_share": "ایجاد لینک برای اشتراک‌گذاری", + "create_new_person": "ایجاد فرد جدید", + "create_new_user": "ایجاد کاربر جدید", + "create_user": "ایجاد کاربر", + "created": "ایجاد شد", + "current_device": "دستگاه فعلی", "custom_locale": "", "custom_locale_description": "", - "dark": "", - "date_after": "", - "date_and_time": "", - "date_before": "", - "date_range": "", - "day": "", - "deduplicate_all": "", + "dark": "تاریک", + "date_after": "تاریخ پس از", + "date_and_time": "تاریخ و زمان", + "date_before": "تاریخ قبل از", + "date_range": "بازه زمانی", + "day": "روز", + "deduplicate_all": "حذف تکراری‌ها به صورت کامل", "default_locale": "", "default_locale_description": "", - "delete": "", - "delete_album": "", + "delete": "حذف", + "delete_album": "حذف آلبوم", "delete_api_key_prompt": "", "delete_duplicates_confirmation": "", - "delete_key": "", - "delete_library": "", - "delete_link": "", - "delete_shared_link": "", - "delete_user": "", - "deleted_shared_link": "", - "description": "", - "details": "", - "direction": "", - "disabled": "", - "disallow_edits": "", - "discover": "", - "dismiss_all_errors": "", - "dismiss_error": "", - "display_options": "", - "display_order": "", - "display_original_photos": "", + "delete_key": "حذف کلید", + "delete_library": "حذف کتابخانه", + "delete_link": "حذف لینک", + "delete_shared_link": "حذف لینک اشتراکی", + "delete_user": "حذف کاربر", + "deleted_shared_link": "لینک اشتراکی حذف شد", + "description": "توضیحات", + "details": "جزئیات", + "direction": "جهت", + "disabled": "غیرفعال", + "disallow_edits": "عدم اجازه ویرایش", + "discover": "کشف کردن", + "dismiss_all_errors": "رد تمام خطاها", + "dismiss_error": "رد خطا", + "display_options": "گزینه‌های نمایش", + "display_order": "ترتیب نمایش", + "display_original_photos": "نمایش عکس‌های اصلی", "display_original_photos_setting_description": "", - "done": "", - "download": "", - "download_settings": "", - "download_settings_description": "", - "downloading": "", - "duplicates": "", + "done": "انجام شد", + "download": "دانلود", + "download_settings": "تنظیمات دانلود", + "download_settings_description": "مدیریت تنظیمات مرتبط با دانلود محتوا", + "downloading": "در حال دانلود", + "duplicates": "تکراری‌ها", "duplicates_description": "", - "duration": "", - "edit_album": "", - "edit_avatar": "", - "edit_date": "", - "edit_date_and_time": "", - "edit_exclusion_pattern": "", - "edit_faces": "", + "duration": "مدت زمان", + "edit_album": "ویرایش آلبوم", + "edit_avatar": "ویرایش آواتار", + "edit_date": "ویرایش تاریخ", + "edit_date_and_time": "ویرایش تاریخ و زمان", + "edit_exclusion_pattern": "ویرایش الگوی استثناء", + "edit_faces": "ویرایش چهره‌ها", "edit_import_path": "", "edit_import_paths": "", - "edit_key": "", - "edit_link": "", - "edit_location": "", - "edit_name": "", - "edit_people": "", - "edit_title": "", - "edit_user": "", - "edited": "", - "editor": "", - "email": "", - "empty_trash": "", - "end_date": "", - "error": "", - "error_loading_image": "", + "edit_key": "ویرایش کلید", + "edit_link": "ویرایش لینک", + "edit_location": "ویرایش مکان", + "edit_name": "ویرایش نام", + "edit_people": "ویرایش افراد", + "edit_title": "ویرایش عنوان", + "edit_user": "ویرایش کاربر", + "edited": "ویرایش شد", + "editor": "ویرایشگر", + "email": "ایمیل", + "empty_trash": "خالی کردن سطل زباله", + "end_date": "تاریخ پایان", + "error": "خطا", + "error_loading_image": "خطا در بارگذاری تصویر", "errors": { "exclusion_pattern_already_exists": "", "import_path_already_exists": "", @@ -530,400 +530,400 @@ "unable_to_update_timeline_display_status": "", "unable_to_update_user": "" }, - "exit_slideshow": "", - "expand_all": "", - "expire_after": "", - "expired": "", - "explore": "", - "export": "", - "export_as_json": "", - "extension": "", - "external": "", - "external_libraries": "", - "favorite": "", + "exit_slideshow": "خروج از نمایش اسلاید", + "expand_all": "باز کردن همه", + "expire_after": "منقضی شدن بعد از", + "expired": "منقضی شده", + "explore": "کاوش کردن", + "export": "صادر کردن", + "export_as_json": "صادر کردن به‌صورت JSON", + "extension": "پسوند", + "external": "خارجی", + "external_libraries": "کتابخانه‌های خارجی", + "favorite": "علاقه‌مندی", "favorite_or_unfavorite_photo": "", - "favorites": "", + "favorites": "علاقه‌مندی‌ها", "feature_photo_updated": "", - "file_name": "", - "file_name_or_extension": "", - "filename": "", - "filetype": "", - "filter_people": "", + "file_name": "نام فایل", + "file_name_or_extension": "نام فایل یا پسوند", + "filename": "نام فایل", + "filetype": "نوع فایل", + "filter_people": "فیلتر افراد", "find_them_fast": "", - "fix_incorrect_match": "", - "forward": "", - "general": "", - "get_help": "", - "getting_started": "", - "go_back": "", - "go_to_search": "", - "group_albums_by": "", - "has_quota": "", - "hide_gallery": "", - "hide_password": "", - "hide_person": "", - "host": "", - "hour": "", - "image": "", - "immich_logo": "", - "immich_web_interface": "", - "import_from_json": "", - "import_path": "", + "fix_incorrect_match": "رفع تطابق نادرست", + "forward": "جلو", + "general": "عمومی", + "get_help": "دریافت کمک", + "getting_started": "شروع به کار", + "go_back": "بازگشت", + "go_to_search": "رفتن به جستجو", + "group_albums_by": "گروه‌بندی آلبوم‌ها براساس...", + "has_quota": "دارای سهمیه", + "hide_gallery": "پنهان کردن گالری", + "hide_password": "پنهان کردن رمز عبور", + "hide_person": "پنهان کردن فرد", + "host": "میزبان", + "hour": "ساعت", + "image": "تصویر", + "immich_logo": "لوگوی Immich", + "immich_web_interface": "رابط وب Immich", + "import_from_json": "وارد کردن از JSON", + "import_path": "مسیر وارد کردن", "in_albums": "", - "in_archive": "", - "include_archived": "", - "include_shared_albums": "", + "in_archive": "در بایگانی", + "include_archived": "شامل بایگانی شده‌ها", + "include_shared_albums": "شامل آلبوم‌های اشتراکی", "include_shared_partner_assets": "", - "individual_share": "", - "info": "", + "individual_share": "اشتراک فردی", + "info": "اطلاعات", "interval": { "day_at_onepm": "", "hours": "", "night_at_midnight": "", "night_at_twoam": "" }, - "invite_people": "", - "invite_to_album": "", - "jobs": "", - "keep": "", - "keep_all": "", - "keyboard_shortcuts": "", - "language": "", - "language_setting_description": "", - "last_seen": "", - "leave": "", - "let_others_respond": "", - "level": "", - "library": "", - "library_options": "", - "light": "", - "link_options": "", - "link_to_oauth": "", - "linked_oauth_account": "", - "list": "", - "loading": "", - "loading_search_results_failed": "", - "log_out": "", - "log_out_all_devices": "", - "login_has_been_disabled": "", - "look": "", - "loop_videos": "", + "invite_people": "دعوت افراد", + "invite_to_album": "دعوت به آلبوم", + "jobs": "وظایف", + "keep": "نگه داشتن", + "keep_all": "نگه داشتن همه", + "keyboard_shortcuts": "میانبرهای صفحه‌کلید", + "language": "زبان", + "language_setting_description": "انتخاب زبان دلخواه شما", + "last_seen": "آخرین مشاهده", + "leave": "ترک کردن", + "let_others_respond": "اجازه به دیگران برای پاسخ‌گویی", + "level": "سطح", + "library": "کتابخانه", + "library_options": "گزینه‌های کتابخانه", + "light": "روشن", + "link_options": "گزینه‌های لینک", + "link_to_oauth": "اتصال به OAuth", + "linked_oauth_account": "حساب OAuth متصل شده", + "list": "لیست", + "loading": "در حال بارگذاری", + "loading_search_results_failed": "بارگذاری نتایج جستجو ناموفق بود", + "log_out": "خروج از سیستم", + "log_out_all_devices": "خروج از همه دستگاه‌ها", + "login_has_been_disabled": "ورود غیرفعال شده است.", + "look": "نگاه کردن", + "loop_videos": "پخش مداوم ویدئوها", "loop_videos_description": "", - "make": "", - "manage_shared_links": "", + "make": "ساختن", + "manage_shared_links": "مدیریت لینک‌های اشتراکی", "manage_sharing_with_partners": "", - "manage_the_app_settings": "", - "manage_your_account": "", - "manage_your_api_keys": "", - "manage_your_devices": "", - "manage_your_oauth_connection": "", - "map": "", + "manage_the_app_settings": "مدیریت تنظیمات برنامه", + "manage_your_account": "مدیریت حساب کاربری شما", + "manage_your_api_keys": "مدیریت کلیدهای API شما", + "manage_your_devices": "مدیریت دستگاه‌های متصل", + "manage_your_oauth_connection": "مدیریت اتصال OAuth شما", + "map": "نقشه", "map_marker_with_image": "", - "map_settings": "", - "matches": "", - "media_type": "", - "memories": "", + "map_settings": "تنظیمات نقشه", + "matches": "تطابق‌ها", + "media_type": "نوع رسانه", + "memories": "خاطرات", "memories_setting_description": "", - "memory": "", - "menu": "", - "merge": "", - "merge_people": "", + "memory": "خاطره", + "menu": "منو", + "merge": "ادغام", + "merge_people": "ادغام افراد", "merge_people_limit": "", "merge_people_prompt": "", - "merge_people_successfully": "", - "minimize": "", - "minute": "", - "missing": "", - "model": "", - "month": "", - "more": "", - "moved_to_trash": "", - "my_albums": "", - "name": "", - "name_or_nickname": "", - "never": "", - "new_api_key": "", - "new_password": "", - "new_person": "", - "new_user_created": "", - "newest_first": "", - "next": "", - "next_memory": "", - "no": "", + "merge_people_successfully": "ادغام افراد با موفقیت انجام شد", + "minimize": "کوچک کردن", + "minute": "دقیقه", + "missing": "گمشده", + "model": "مدل", + "month": "ماه", + "more": "بیشتر", + "moved_to_trash": "به سطل زباله منتقل شد", + "my_albums": "آلبوم‌های من", + "name": "نام", + "name_or_nickname": "نام یا لقب", + "never": "هرگز", + "new_api_key": "کلید API جدید", + "new_password": "رمز عبور جدید", + "new_person": "فرد جدید", + "new_user_created": "کاربر جدید ایجاد شد", + "newest_first": "جدیدترین ابتدا", + "next": "بعدی", + "next_memory": "خاطره بعدی", + "no": "خیر", "no_albums_message": "", "no_archived_assets_message": "", "no_assets_message": "", - "no_duplicates_found": "", - "no_exif_info_available": "", + "no_duplicates_found": "هیچ تکراری یافت نشد.", + "no_exif_info_available": "اطلاعات EXIF موجود نیست", "no_explore_results_message": "", "no_favorites_message": "", "no_libraries_message": "", - "no_name": "", - "no_places": "", - "no_results": "", + "no_name": "بدون نام", + "no_places": "مکانی یافت نشد", + "no_results": "نتیجه‌ای یافت نشد", "no_shared_albums_message": "", - "not_in_any_album": "", + "not_in_any_album": "در هیچ آلبومی نیست", "note_apply_storage_label_to_previously_uploaded assets": "", "note_unlimited_quota": "", - "notes": "", - "notification_toggle_setting_description": "", - "notifications": "", - "notifications_setting_description": "", - "oauth": "", - "offline": "", - "offline_paths": "", + "notes": "یادداشت‌ها", + "notification_toggle_setting_description": "اعلان‌های ایمیلی را فعال کنید", + "notifications": "اعلان‌ها", + "notifications_setting_description": "مدیریت اعلان‌ها", + "oauth": "OAuth", + "offline": "آفلاین", + "offline_paths": "مسیرهای آفلاین", "offline_paths_description": "", - "ok": "", - "oldest_first": "", - "online": "", - "only_favorites": "", - "open_the_search_filters": "", - "options": "", - "organize_your_library": "", - "other": "", - "other_devices": "", - "other_variables": "", - "owned": "", - "owner": "", - "partner": "", - "partner_can_access": "", + "ok": "تأیید", + "oldest_first": "قدیمی‌ترین ابتدا", + "online": "آنلاین", + "only_favorites": "فقط علاقه‌مندی‌ها", + "open_the_search_filters": "باز کردن فیلترهای جستجو", + "options": "گزینه‌ها", + "organize_your_library": "کتابخانه خود را سازماندهی کنید", + "other": "دیگر", + "other_devices": "دستگاه‌های دیگر", + "other_variables": "متغیرهای دیگر", + "owned": "مالکیت", + "owner": "مالک", + "partner": "شریک", + "partner_can_access": "{partner} می‌تواند دسترسی داشته باشد", "partner_can_access_assets": "", - "partner_can_access_location": "", - "partner_sharing": "", - "partners": "", - "password": "", - "password_does_not_match": "", - "password_required": "", - "password_reset_success": "", + "partner_can_access_location": "مکان‌هایی که عکس‌های شما گرفته شده‌اند", + "partner_sharing": "اشتراک‌گذاری با شریک", + "partners": "شرکا", + "password": "رمز عبور", + "password_does_not_match": "رمز عبور مطابقت ندارد", + "password_required": "رمز عبور مورد نیاز است", + "password_reset_success": "بازنشانی رمز عبور موفقیت‌آمیز بود", "past_durations": { "days": "", "hours": "", "years": "" }, - "path": "", - "pattern": "", - "pause": "", - "pause_memories": "", - "paused": "", - "pending": "", - "people": "", + "path": "مسیر", + "pattern": "الگو", + "pause": "توقف", + "pause_memories": "توقف خاطرات", + "paused": "متوقف شده", + "pending": "در انتظار", + "people": "افراد", "people_sidebar_description": "", - "permanent_deletion_warning": "", - "permanent_deletion_warning_setting_description": "", - "permanently_delete": "", - "permanently_deleted_asset": "", - "person": "", - "photos": "", + "permanent_deletion_warning": "هشدار حذف دائمی", + "permanent_deletion_warning_setting_description": "نمایش هشدار هنگام حذف دائمی محتواها", + "permanently_delete": "حذف دائمی", + "permanently_deleted_asset": "محتوای حذف شده دائمی", + "person": "فرد", + "photos": "عکس‌ها", "photos_count": "", - "photos_from_previous_years": "", - "pick_a_location": "", - "place": "", - "places": "", - "play": "", - "play_memories": "", - "play_motion_photo": "", - "play_or_pause_video": "", - "port": "", - "preset": "", - "preview": "", - "previous": "", - "previous_memory": "", - "previous_or_next_photo": "", - "primary": "", - "profile_picture_set": "", - "public_share": "", - "reaction_options": "", - "read_changelog": "", - "recent": "", - "recent_searches": "", - "refresh": "", - "refreshed": "", + "photos_from_previous_years": "عکس‌های سال‌های گذشته", + "pick_a_location": "یک مکان انتخاب کنید", + "place": "مکان", + "places": "مکان‌ها", + "play": "پخش", + "play_memories": "پخش خاطرات", + "play_motion_photo": "پخش عکس متحرک", + "play_or_pause_video": "پخش یا توقف ویدیو", + "port": "پورت", + "preset": "پیش‌فرض", + "preview": "پیش‌نمایش", + "previous": "قبلی", + "previous_memory": "خاطره قبلی", + "previous_or_next_photo": "عکس قبلی یا بعدی", + "primary": "اصلی", + "profile_picture_set": "تصویر پروفایل تنظیم شد.", + "public_share": "اشتراک عمومی", + "reaction_options": "گزینه‌های واکنش", + "read_changelog": "مطالعه تغییرات نسخه", + "recent": "اخیر", + "recent_searches": "جستجوهای اخیر", + "refresh": "تازه سازی", + "refreshed": "تازه سازی شد", "refreshes_every_file": "", - "remove": "", - "remove_deleted_assets": "", - "remove_from_album": "", - "remove_from_favorites": "", + "remove": "حذف", + "remove_deleted_assets": "حذف محتواهای حذف‌شده", + "remove_from_album": "حذف از آلبوم", + "remove_from_favorites": "حذف از علاقه‌مندی‌ها", "remove_from_shared_link": "", "removed_api_key": "", - "rename": "", - "repair": "", + "rename": "تغییر نام", + "repair": "تعمیر", "repair_no_results_message": "", - "replace_with_upload": "", + "replace_with_upload": "جایگزینی با آپلود", "require_password": "", "require_user_to_change_password_on_first_login": "", - "reset": "", - "reset_password": "", + "reset": "بازنشانی", + "reset_password": "بازنشانی رمز عبور", "reset_people_visibility": "", "resolved_all_duplicates": "", - "restore": "", - "restore_all": "", - "restore_user": "", - "resume": "", + "restore": "بازیابی", + "restore_all": "بازیابی همه", + "restore_user": "بازیابی کاربر", + "resume": "ادامه", "retry_upload": "", - "review_duplicates": "", - "role": "", - "save": "", + "review_duplicates": "بررسی تکراری‌ها", + "role": "نقش", + "save": "ذخیره", "saved_api_key": "", - "saved_profile": "", - "saved_settings": "", - "say_something": "", - "scan_all_libraries": "", - "scan_settings": "", + "saved_profile": "پروفایل ذخیره شد", + "saved_settings": "تنظیمات ذخیره شد", + "say_something": "چیزی بگویید", + "scan_all_libraries": "اسکن همه کتابخانه‌ها", + "scan_settings": "تنظیمات اسکن", "scanning_for_album": "", - "search": "", - "search_albums": "", - "search_by_context": "", - "search_camera_make": "", - "search_camera_model": "", - "search_city": "", - "search_country": "", - "search_for_existing_person": "", - "search_people": "", - "search_places": "", - "search_state": "", - "search_timezone": "", - "search_type": "", + "search": "جستجو", + "search_albums": "جستجوی آلبوم‌ها", + "search_by_context": "جستجو براساس زمینه", + "search_camera_make": "جستجوی برند دوربین...", + "search_camera_model": "جستجوی مدل دوربین...", + "search_city": "جستجوی شهر...", + "search_country": "جستجوی کشور...", + "search_for_existing_person": "جستجوی فرد موجود", + "search_people": "جستجوی افراد", + "search_places": "جستجوی مکان‌ها", + "search_state": "جستجوی ایالت...", + "search_timezone": "جستجوی منطقه زمانی...", + "search_type": "نوع جستجو", "search_your_photos": "", "searching_locales": "", - "second": "", - "select_album_cover": "", - "select_all": "", - "select_avatar_color": "", - "select_face": "", - "select_featured_photo": "", - "select_keep_all": "", - "select_library_owner": "", - "select_new_face": "", - "select_photos": "", + "second": "ثانیه", + "select_album_cover": "انتخاب جلد آلبوم", + "select_all": "انتخاب همه", + "select_avatar_color": "انتخاب رنگ آواتار", + "select_face": "انتخاب چهره", + "select_featured_photo": "انتخاب عکس ویژه", + "select_keep_all": "انتخاب نگهداری همه", + "select_library_owner": "انتخاب مالک کتابخانه", + "select_new_face": "انتخاب چهره جدید", + "select_photos": "انتخاب عکس‌ها", "select_trash_all": "", - "selected": "", - "send_message": "", - "send_welcome_email": "", - "server_stats": "", - "set": "", + "selected": "انتخاب شده", + "send_message": "ارسال پیام", + "send_welcome_email": "ارسال ایمیل خوش‌آمدگویی", + "server_stats": "آمار سرور", + "set": "تنظیم", "set_as_album_cover": "", "set_as_profile_picture": "", - "set_date_of_birth": "", - "set_profile_picture": "", + "set_date_of_birth": "تنظیم تاریخ تولد", + "set_profile_picture": "تنظیم تصویر پروفایل", "set_slideshow_to_fullscreen": "", - "settings": "", - "settings_saved": "", - "share": "", - "shared": "", - "shared_by": "", + "settings": "تنظیمات", + "settings_saved": "تنظیمات ذخیره شد", + "share": "اشتراک‌گذاری", + "shared": "مشترک", + "shared_by": "مشترک توسط", "shared_by_you": "", - "shared_from_partner": "", - "shared_links": "", + "shared_from_partner": "عکس‌ها از {partner}", + "shared_links": "لینک‌های اشتراکی", "shared_photos_and_videos_count": "", - "shared_with_partner": "", - "sharing": "", + "shared_with_partner": "مشترک با {partner}", + "sharing": "اشتراک‌گذاری", "sharing_sidebar_description": "", - "show_album_options": "", + "show_album_options": "نمایش گزینه‌های آلبوم", "show_and_hide_people": "", - "show_file_location": "", - "show_gallery": "", - "show_hidden_people": "", + "show_file_location": "نمایش مسیر فایل", + "show_gallery": "نمایش گالری", + "show_hidden_people": "نمایش افراد پنهان", "show_in_timeline": "", "show_in_timeline_setting_description": "", "show_keyboard_shortcuts": "", - "show_metadata": "", + "show_metadata": "نمایش اطلاعات متا", "show_or_hide_info": "", - "show_password": "", + "show_password": "نمایش رمز عبور", "show_person_options": "", - "show_progress_bar": "", - "show_search_options": "", - "shuffle": "", - "sign_out": "", - "sign_up": "", - "size": "", - "skip_to_content": "", - "slideshow": "", - "slideshow_settings": "", + "show_progress_bar": "نمایش نوار پیشرفت", + "show_search_options": "نمایش گزینه‌های جستجو", + "shuffle": "تصادفی", + "sign_out": "خروج", + "sign_up": "ثبت‌نام", + "size": "اندازه", + "skip_to_content": "رفتن به محتوا", + "slideshow": "نمایش اسلاید", + "slideshow_settings": "تنظیمات نمایش اسلاید", "sort_albums_by": "", - "stack": "", + "stack": "پشته", "stack_selected_photos": "", "stacktrace": "", - "start": "", - "start_date": "", - "state": "", - "status": "", - "stop_motion_photo": "", + "start": "شروع", + "start_date": "تاریخ شروع", + "state": "ایالت", + "status": "وضعیت", + "stop_motion_photo": "توقف عکس متحرک", "stop_photo_sharing": "", "stop_photo_sharing_description": "", "stop_sharing_photos_with_user": "", - "storage": "", - "storage_label": "", + "storage": "فضای ذخیره‌سازی", + "storage_label": "برچسب فضای ذخیره‌سازی", "storage_usage": "", - "submit": "", - "suggestions": "", + "submit": "ارسال", + "suggestions": "پیشنهادات", "sunrise_on_the_beach": "", - "swap_merge_direction": "", - "sync": "", - "template": "", - "theme": "", - "theme_selection": "", + "swap_merge_direction": "تغییر جهت ادغام", + "sync": "همگام‌سازی", + "template": "الگو", + "theme": "تم", + "theme_selection": "انتخاب تم", "theme_selection_description": "", "time_based_memories": "", - "timezone": "", - "to_archive": "", - "to_favorite": "", + "timezone": "منطقه زمانی", + "to_archive": "بایگانی", + "to_favorite": "به علاقه‌مندی‌ها", "to_trash": "", - "toggle_settings": "", - "toggle_theme": "", - "total_usage": "", - "trash": "", + "toggle_settings": "تغییر تنظیمات", + "toggle_theme": "تغییر تم تاریک", + "total_usage": "استفاده کلی", + "trash": "سطل زباله", "trash_all": "", "trash_count": "", "trash_no_results_message": "", "trashed_items_will_be_permanently_deleted_after": "", - "type": "", + "type": "نوع", "unarchive": "", - "unfavorite": "", - "unhide_person": "", - "unknown": "", - "unknown_year": "", - "unlimited": "", - "unlink_oauth": "", + "unfavorite": "حذف از علاقه‌مندی‌ها", + "unhide_person": "آشکار کردن فرد", + "unknown": "ناشناخته", + "unknown_year": "سال نامشخص", + "unlimited": "نامحدود", + "unlink_oauth": "لغو اتصال OAuth", "unlinked_oauth_account": "", - "unnamed_album": "", - "unnamed_share": "", - "unselect_all": "", + "unnamed_album": "آلبوم بدون نام", + "unnamed_share": "اشتراک بدون نام", + "unselect_all": "لغو انتخاب همه", "unstack": "", "untracked_files": "", "untracked_files_decription": "", - "up_next": "", + "up_next": "مورد بعدی", "updated_password": "", - "upload": "", - "upload_concurrency": "", - "url": "", - "usage": "", - "user": "", - "user_id": "", - "user_usage_detail": "", - "username": "", - "users": "", - "utilities": "", - "validate": "", - "variables": "", - "version": "", + "upload": "آپلود", + "upload_concurrency": "تعداد آپلود همزمان", + "url": "آدرس", + "usage": "استفاده", + "user": "کاربر", + "user_id": "شناسه کاربر", + "user_usage_detail": "جزئیات استفاده کاربر", + "username": "نام کاربری", + "users": "کاربران", + "utilities": "ابزارها", + "validate": "اعتبارسنجی", + "variables": "متغیرها", + "version": "نسخه", "version_announcement_message": "", - "video": "", + "video": "ویدیو", "video_hover_setting": "", "video_hover_setting_description": "", - "videos": "", + "videos": "ویدیوها", "videos_count": "", - "view": "", - "view_all": "", - "view_all_users": "", - "view_links": "", - "view_next_asset": "", - "view_previous_asset": "", - "waiting": "", - "week": "", - "welcome": "", + "view": "مشاهده", + "view_all": "مشاهده همه", + "view_all_users": "مشاهده همه کاربران", + "view_links": "مشاهده لینک‌ها", + "view_next_asset": "مشاهده محتوای بعدی", + "view_previous_asset": "مشاهده محتوای قبلی", + "waiting": "در انتظار", + "week": "هفته", + "welcome": "خوش آمدید", "welcome_to_immich": "", - "year": "", - "yes": "", + "year": "سال", + "yes": "بله", "you_dont_have_any_shared_links": "", "zoom_image": "بزرگنمایی تصویر" -} +} \ No newline at end of file diff --git a/i18n/fi.json b/i18n/fi.json index e67178782c..ea36a994b4 100644 --- a/i18n/fi.json +++ b/i18n/fi.json @@ -20,7 +20,7 @@ "add_partner": "Lisää kumppani", "add_path": "Lisää polku", "add_photos": "Lisää kuvia", - "add_to": "Lisää...", + "add_to": "Lisää…", "add_to_album": "Lisää albumiin", "add_to_shared_album": "Lisää jaettuun albumiin", "add_url": "Lisää URL", @@ -299,7 +299,7 @@ "transcoding_max_b_frames": "B-kehysten enimmäismäärä", "transcoding_max_b_frames_description": "Korkeampi arvo parantaa pakkausta, mutta hidastaa enkoodausta. Ei välttämättä ole yhteensopiva vanhempien laitteiden kanssa. 0 poistaa B-kehykset käytöstä, -1 määrittää arvon automaattisesti.", "transcoding_max_bitrate": "Suurin bittinopeus", - "transcoding_max_bitrate_description": "Suurimman sallitun bittinopeuden asettaminen tekee tiedostojen koosta ennustettavampaa vaikka laatu voi hieman heiketä. 720p videossa tyypilliset arvot ovat 2600k VP9:lle ja HEVC:lle, tai 4500k H.254:lle. Jos 0, ei käytössä.", + "transcoding_max_bitrate_description": "Suurimman sallitun bittinopeuden asettaminen tekee tiedostojen koosta ennustettavampaa vaikka laatu voi hieman heiketä. 720p videossa tyypilliset arvot ovat 2600 kbit/s VP9:lle ja HEVC:lle, tai 4500 kbit/s H.254:lle. Jos 0, ei käytössä.", "transcoding_max_keyframe_interval": "Suurin avainkehysten väli", "transcoding_max_keyframe_interval_description": "Asettaa avainkehysten välin maksimiarvon. Alempi arvo huonontaa pakkauksen tehoa, mutta parantaa hakuaikoja ja voi parantaa laatua nopealiikkeisissä kohtauksissa. 0 asettaa arvon automaattisesti.", "transcoding_optimal_description": "Videot, joiden resoluutio on korkeampi kuin kohteen, tai ei hyväksytyssä formaatissa", @@ -540,7 +540,7 @@ "delete_shared_link": "Poista jaettu linkki", "delete_tag": "Poista tunniste", "delete_tag_confirmation_prompt": "Haluatko varmasti poistaa tunnisteen {tagName}?", - "delete_user": "Poista käyttäjä pysyvästi", + "delete_user": "Poista käyttäjä", "deleted_shared_link": "Jaettu linkki poistettu", "deletes_missing_assets": "Poistaa levyltä puuttuvat resurssit", "description": "Kuvaus", @@ -766,8 +766,10 @@ "go_to_folder": "Mene kansioon", "go_to_search": "Siirry hakuun", "group_albums_by": "Ryhmitä albumi...", + "group_country": "Ryhmitä maan mukaan", "group_no": "Ei ryhmitystä", "group_owner": "Ryhmitä omistajan mukaan", + "group_places_by": "Ryhmitä paikat...", "group_year": "Ryhmitä vuoden mukaan", "has_quota": "On kiintiö", "hi_user": "Hei {name} ({email})", @@ -800,6 +802,7 @@ "include_shared_albums": "Sisällytä jaetut albumit", "include_shared_partner_assets": "Sisällytä jaetut kumppanikohteet", "individual_share": "Yksittäinen jako", + "individual_shares": "Yksittäiset jaot", "info": "Lisätietoja", "interval": { "day_at_onepm": "Joka päivä klo 13:00", @@ -1107,6 +1110,7 @@ "search": "Haku", "search_albums": "Etsi albumeita", "search_by_context": "Etsi kontekstin perusteella", + "search_by_description": "Etsi kuvauksen perusteella", "search_by_filename": "Hae tiedostonimen tai -päätteen mukaan", "search_by_filename_example": "esim. IMG_1234.JPG tai PNG", "search_camera_make": "Etsi kameramerkkiä...", @@ -1348,4 +1352,4 @@ "yes": "Kyllä", "you_dont_have_any_shared_links": "Sinulla ei ole jaettuja linkkejä", "zoom_image": "Zoomaa kuvaa" -} +} \ No newline at end of file diff --git a/i18n/fr.json b/i18n/fr.json index 2b5635fd91..3f479ab28f 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -41,6 +41,7 @@ "backup_settings": "Paramètres de la sauvegarde", "backup_settings_description": "Gérer les paramètres de la sauvegarde", "check_all": "Tout cocher", + "cleanup": "Nettoyage", "cleared_jobs": "Tâches supprimées pour : {job}", "config_set_by_file": "La configuration est actuellement définie par un fichier de configuration", "confirm_delete_library": "Êtes-vous sûr de vouloir supprimer la bibliothèque {library} ?", @@ -59,7 +60,7 @@ "external_library_management": "Gestion de la bibliothèque externe", "face_detection": "Détection des visages", "face_detection_description": "Détection des visages dans les médias à l'aide de l'apprentissage automatique. Pour les vidéos, seule la miniature est prise en compte. « Actualiser » (re)traite tous les médias. « Réinitialiser » retraite tous les médias en repartant de zéro. « Manquant » met en file d'attente les médias qui n'ont pas encore été pris en compte. Lorsque la détection est terminée, tous les visages détectés sont ensuite mis en file d'attente pour la reconnaissance faciale.", - "facial_recognition_job_description": "Regrouper les visages détectés en personnes. Cette étape est exécutée une fois la détection des visages terminée. « Rafraichir » (re)regroupe tous les visages. « Manquant » met en file d'attente les visages auxquels aucune personne n'a été attribuée.", + "facial_recognition_job_description": "Regrouper les visages détectés en personnes. Cette étape est exécutée une fois la détection des visages terminée. « Réinitialiser » (re)regroupe tous les visages. « Manquant » met en file d'attente les visages auxquels aucune personne n'a été attribuée.", "failed_job_command": "La commande {command} a échoué pour la tâche : {job}", "force_delete_user_warning": "ATTENTION : Cette opération entraîne la suppression immédiate de l'utilisateur et de tous ses médias. Cette opération ne peut être annulée et les fichiers ne peuvent être récupérés.", "forcing_refresh_library_files": "Forcer le rafraîchissement de tous les fichiers de la bibliothèque", @@ -96,7 +97,7 @@ "library_scanning_enable_description": "Activer l'analyse périodique de la bibliothèque", "library_settings": "Bibliothèque externe", "library_settings_description": "Gestion des paramètres des bibliothèques externes", - "library_tasks_description": "Exécution d'actions sur la bibliothèque", + "library_tasks_description": "Scanner les bibliothèques externes pour les nouveaux et/ou les éléments modifiés", "library_watching_enable_description": "Surveiller les modifications de fichiers dans les bibliothèques externes", "library_watching_settings": "Surveillance de bibliothèque (EXPÉRIMENTAL)", "library_watching_settings_description": "Surveiller automatiquement les fichiers modifiés", @@ -131,7 +132,7 @@ "machine_learning_smart_search_description": "Rechercher des images de manière sémantique en utilisant les intégrations CLIP", "machine_learning_smart_search_enabled": "Activer la recherche intelligente", "machine_learning_smart_search_enabled_description": "Si cette option est désactivée, les images ne seront pas encodées pour la recherche intelligente.", - "machine_learning_url_description": "L’URL du serveur d'apprentissage automatique. Si plusieurs URL sont fournies, chaque serveur sera essayé un par un jusqu’à ce que l’un d’eux réponde avec succès, dans l’ordre de la première à la dernière.", + "machine_learning_url_description": "L’URL du serveur d'apprentissage automatique. Si plusieurs URL sont fournies, chaque serveur sera essayé un par un jusqu’à ce que l’un d’eux réponde avec succès, dans l’ordre de la première à la dernière. Les serveurs ne répondant pas seront temporairement ignorés jusqu'à ce qu'ils soient de nouveau opérationnels.", "manage_concurrency": "Gérer du multitâche", "manage_log_settings": "Gérer les paramètres de journalisation", "map_dark_style": "Thème sombre", @@ -147,6 +148,8 @@ "map_settings": "Carte", "map_settings_description": "Gérer les paramètres de la carte", "map_style_description": "URL vers un thème de carte au format style.json", + "memory_cleanup_job": "Nettoyage des souvenirs", + "memory_generate_job": "Génération des souvenirs", "metadata_extraction_job": "Extraction des métadonnées", "metadata_extraction_job_description": "Extraction des informations des métadonnées de chaque média, telles que la position GPS, les visages et la résolution", "metadata_faces_import_setting": "Active l'importation des visages", @@ -219,7 +222,7 @@ "reset_settings_to_default": "Réinitialiser les paramètres par défaut", "reset_settings_to_recent_saved": "Paramètres réinitialisés avec les derniers paramètres enregistrés", "scanning_library": "Analyse de la bibliothèque", - "search_jobs": "Recherche des tâches ...", + "search_jobs": "Recherche des tâches…", "send_welcome_email": "Envoyer un courriel de bienvenue", "server_external_domain_settings": "Domaine externe", "server_external_domain_settings_description": "Nom de domaine pour les liens partagés publics, y compris http(s)://", @@ -240,7 +243,7 @@ "storage_template_hash_verification_enabled_description": "Active la vérification du hachage, ne désactivez pas cette option à moins d'être sûr de ce que vous faites", "storage_template_migration": "Migration du modèle de stockage", "storage_template_migration_description": "Appliquer le modèle courant {template} aux médias précédemment envoyés", - "storage_template_migration_info": "Les changements de modèle ne s'appliqueront qu'aux nouveaux médias. Pour appliquer rétroactivement le modèle aux médias précédemment envoyés, exécutez la tâche {job}.", + "storage_template_migration_info": "L'enregistrement des modèles convertit toutes les extensions en minuscule. Les changements de modèle ne s'appliqueront qu'aux nouveaux médias. Pour appliquer rétroactivement le modèle aux médias précédemment envoyés, exécutez la tâche {job}.", "storage_template_migration_job": "Tâche de migration du modèle de stockage", "storage_template_more_details": "Pour plus de détails sur cette fonctionnalité, reportez-vous au Modèle de stockage et à ses implications", "storage_template_onboarding_description": "Lorsqu'elle est activée, cette fonctionnalité réorganise les fichiers basés sur un modèle défini par l'utilisateur. En raison de problèmes de stabilité, la fonction a été désactivée par défaut. Pour plus d'informations, veuillez consulter la documentation.", @@ -299,7 +302,7 @@ "transcoding_max_b_frames": "Nombre maximum de trames B", "transcoding_max_b_frames_description": "Des valeurs plus élevées améliorent l'efficacité de la compression, mais ralentissent l'encodage. Elles peuvent ne pas être compatibles avec l'accélération matérielle sur les anciens appareils. Une valeur de 0 désactive les trames B, tandis qu'une valeur de -1 définit automatiquement ce paramètre.", "transcoding_max_bitrate": "Débit binaire maximal", - "transcoding_max_bitrate_description": "Définir un débit binaire maximal peut résulter en des fichiers de taille plus prédictible, au prix d'une légère perte en qualité. En 720p, les valeurs sont 2600k pour du VP9 ou du HEVC ou 4500k pour du H.264. Désactivé si le débit binaire est à 0.", + "transcoding_max_bitrate_description": "Définir un débit binaire maximal peut résulter en des fichiers de taille plus prédictible, au prix d'une légère perte en qualité. En 720p, les valeurs sont 2600 kbit/s pour du VP9 ou du HEVC ou 4500 kbit/s pour du H.264. Désactivé si le débit binaire est à 0.", "transcoding_max_keyframe_interval": "Intervalle maximal entre les images clés", "transcoding_max_keyframe_interval_description": "Définit la distance maximale de trames entre les images clés. Les valeurs plus basses diminuent l'efficacité de la compression, mais améliorent les temps de recherche et peuvent améliorer la qualité dans les scènes avec des mouvements rapides. Une valeur de 0 définit automatiquement ce paramètre.", "transcoding_optimal_description": "Les vidéos dont la résolution est supérieure à celle attendue ou celles qui ne sont pas dans un format accepté", @@ -391,6 +394,7 @@ "allow_edits": "Autoriser les modifications", "allow_public_user_to_download": "Permettre aux utilisateurs non connectés de télécharger", "allow_public_user_to_upload": "Permettre l'envoi aux utilisateurs non connectés", + "alt_text_qr_code": "Image du code QR", "anti_clockwise": "Sens anti-horaire", "api_key": "Clé API", "api_key_description": "Cette valeur ne sera affichée qu'une seule fois. Assurez-vous de la copier avant de fermer la fenêtre.", @@ -406,17 +410,17 @@ "are_these_the_same_person": "Est-ce la même personne ?", "are_you_sure_to_do_this": "Êtes-vous sûr de vouloir faire ceci ?", "asset_added_to_album": "Ajouté à l'album", - "asset_adding_to_album": "Ajout à l'album...", + "asset_adding_to_album": "Ajout à l'album…", "asset_description_updated": "La description du média a été mise à jour", "asset_filename_is_offline": "Le média {filename} est hors ligne", "asset_has_unassigned_faces": "Le média a des visages non attribués", - "asset_hashing": "Hachage...", + "asset_hashing": "Hachage…", "asset_offline": "Média hors ligne", "asset_offline_description": "Ce média externe n'est plus accessible sur le disque. Veuillez contacter votre administrateur Immich pour obtenir de l'aide.", "asset_skipped": "Sauté", "asset_skipped_in_trash": "À la corbeille", "asset_uploaded": "Envoyé", - "asset_uploading": "Envoi...", + "asset_uploading": "Téléversement…", "assets": "Médias", "assets_added_count": "{count, plural, one {# média ajouté} other {# médias ajoutés}}", "assets_added_to_album_count": "{count, plural, one {# média ajouté} other {# médias ajoutés}} à l'album", @@ -481,6 +485,7 @@ "comments_are_disabled": "Les commentaires sont désactivés", "confirm": "Confirmer", "confirm_admin_password": "Confirmer le mot de passe Admin", + "confirm_delete_face": "Êtes-vous sûr de vouloir supprimer le visage de {name} du média ?", "confirm_delete_shared_link": "Voulez-vous vraiment supprimer ce lien partagé ?", "confirm_keep_this_delete_others": "Tous les autres médias dans la pile seront supprimés sauf celui-ci. Êtes-vous sûr de vouloir continuer ?", "confirm_password": "Confirmer le mot de passe", @@ -533,6 +538,7 @@ "delete_album": "Supprimer l'album", "delete_api_key_prompt": "Voulez-vous vraiment supprimer cette clé API ?", "delete_duplicates_confirmation": "Êtes-vous certain de vouloir supprimer définitivement ces doublons ?", + "delete_face": "Supprimer le visage", "delete_key": "Supprimer la clé", "delete_library": "Supprimer la bibliothèque", "delete_link": "Supprimer le lien", @@ -600,6 +606,7 @@ "enabled": "Activé", "end_date": "Date de fin", "error": "Erreur", + "error_delete_face": "Erreur lors de la suppression du visage pour le média", "error_loading_image": "Erreur de chargement de l'image", "error_title": "Erreur - Quelque chose s'est mal passé", "errors": { @@ -766,9 +773,11 @@ "go_to_folder": "Dossier", "go_to_search": "Faire une recherche", "group_albums_by": "Grouper les albums par...", + "group_country": "Grouper par pays", "group_no": "Pas de groupe", - "group_owner": "Groupe par propriétaire", - "group_year": "Groupe par année", + "group_owner": "Grouper par propriétaire", + "group_places_by": "Grouper les lieux par...", + "group_year": "Grouper par année", "has_quota": "Quota", "hi_user": "Bonjour {name} ({email})", "hide_all_people": "Cacher toutes les personnes", @@ -799,7 +808,8 @@ "include_archived": "Inclure les archives", "include_shared_albums": "Inclure les albums partagés", "include_shared_partner_assets": "Inclure les médias partagés du partenaire", - "individual_share": "Partage individuel", + "individual_share": "Partage d'un média unique", + "individual_shares": "Partages d'un média unique", "info": "Information", "interval": { "day_at_onepm": "Tous les jours à 13h", @@ -822,6 +832,7 @@ "latest_version": "Dernière version", "latitude": "Latitude", "leave": "Quitter", + "lens_model": "Modèle d'objectif", "let_others_respond": "Laisser les autres réagir", "level": "Niveau", "library": "Bibliothèque", @@ -880,6 +891,7 @@ "month": "Mois", "more": "Plus", "moved_to_trash": "Déplacé dans la corbeille", + "mute_memories": "Mettre en sourdine les souvenirs", "my_albums": "Mes albums", "name": "Nom", "name_or_nickname": "Nom ou surnom", @@ -984,6 +996,7 @@ "pick_a_location": "Choisissez un lieu", "place": "Lieu", "places": "Lieux", + "places_count": "{count, plural, one {{count, number} Lieu} other {{count, number} Lieux}}", "play": "Jouer", "play_memories": "Lancer les souvenirs", "play_motion_photo": "Jouer la photo animée", @@ -1071,6 +1084,8 @@ "removed_from_archive": "Supprimé de l'archive", "removed_from_favorites": "Supprimé des favoris", "removed_from_favorites_count": "{count, plural, one {# supprimé} other {# supprimés}} des favoris", + "removed_memory": "Souvenir supprimé", + "removed_photo_from_memory": "Photo supprimée du souvenir", "removed_tagged_assets": "Tag supprimé de {count, plural, one {# média} other {# médias}}", "rename": "Renommer", "repair": "Réparer", @@ -1079,6 +1094,7 @@ "repository": "Dépôt", "require_password": "Demander le mot de passe", "require_user_to_change_password_on_first_login": "Demander à l'utilisateur de changer son mot de passe lors de sa première connexion", + "rescan": "Rescanner", "reset": "Réinitialiser", "reset_password": "Réinitialiser le mot de passe", "reset_people_visibility": "Réinitialiser la visibilité des personnes", @@ -1107,18 +1123,22 @@ "search": "Recherche", "search_albums": "Rechercher des albums", "search_by_context": "Rechercher par contexte", + "search_by_description": "Recherche par description", + "search_by_description_example": "Randonnée à Sapa", "search_by_filename": "Rechercher par nom du fichier ou extension", "search_by_filename_example": "Exemple : IMG_1234.JPG ou PNG", "search_camera_make": "Rechercher par marque d'appareil photo...", "search_camera_model": "Rechercher par modèle d'appareil photo...", "search_city": "Rechercher par ville...", "search_country": "Rechercher par pays...", + "search_for": "Chercher", "search_for_existing_person": "Rechercher une personne existante", "search_no_people": "Aucune personne", "search_no_people_named": "Aucune personne nommée « {name} »", "search_options": "Rechercher une option", "search_people": "Rechercher une personne", "search_places": "Rechercher un lieu", + "search_rating": "Chercher par évaluation...", "search_settings": "Paramètres de recherche", "search_state": "Rechercher par état/région...", "search_tags": "Recherche d'étiquettes...", @@ -1165,6 +1185,7 @@ "shared_from_partner": "Photos de {partner}", "shared_link_options": "Options de lien partagé", "shared_links": "Liens partagés", + "shared_links_description": "Partager les photos et vidéos via un lien", "shared_photos_and_videos_count": "{assetCount, plural, other {# photos et vidéos partagées.}}", "shared_with_partner": "Partagé avec {partner}", "sharing": "Partage", @@ -1187,6 +1208,7 @@ "show_person_options": "Afficher les options de personnes", "show_progress_bar": "Afficher la barre de progression", "show_search_options": "Afficher les options de recherche", + "show_shared_links": "Afficher les liens partagés", "show_slideshow_transition": "Afficher la transition du diaporama", "show_supporter_badge": "Badge de contributeur", "show_supporter_badge_description": "Afficher le badge de contributeur", @@ -1240,6 +1262,7 @@ "tag_created": "Étiquette créée : {tag}", "tag_feature_description": "Parcourir les photos et vidéos groupées par thèmes logiques", "tag_not_found_question": "Vous ne trouvez pas une étiquette ? Créer une nouvelle étiquette.", + "tag_people": "Étiqueter les personnes", "tag_updated": "Étiquette mise à jour : {tag}", "tagged_assets": "Étiquette ajoutée à {count, plural, one {# média} other {# médias}}", "tags": "Étiquettes", @@ -1274,11 +1297,13 @@ "unfavorite": "Enlever des favoris", "unhide_person": "Afficher la personne", "unknown": "Inconnu", + "unknown_country": "Pays non connu", "unknown_year": "Année inconnue", "unlimited": "Illimité", "unlink_motion_video": "Détacher la photo animée", "unlink_oauth": "Déconnecter OAuth", "unlinked_oauth_account": "Compte OAuth non connecté", + "unmute_memories": "Réactiver les souvenirs", "unnamed_album": "Album sans nom", "unnamed_album_delete_confirmation": "Êtes-vous sûr de vouloir supprimer cet album ?", "unnamed_share": "Partage sans nom", @@ -1332,6 +1357,7 @@ "view_all": "Voir tout", "view_all_users": "Voir tous les utilisateurs", "view_in_timeline": "Voir dans la vue chronologique", + "view_link": "Voir le lien", "view_links": "Voir les liens", "view_name": "Vue", "view_next_asset": "Voir le média suivant", @@ -1348,4 +1374,4 @@ "yes": "Oui", "you_dont_have_any_shared_links": "Vous n'avez aucun lien partagé", "zoom_image": "Zoomer" -} +} \ No newline at end of file diff --git a/i18n/he.json b/i18n/he.json index daf7b1aa39..67321af4cd 100644 --- a/i18n/he.json +++ b/i18n/he.json @@ -8,39 +8,40 @@ "active": "פעיל", "activity": "פעילות", "activity_changed": "הפעילות {enabled, select, true {מופעלת} other {מושבתת}}", - "add": "הוסף", - "add_a_description": "הוסף תיאור", - "add_a_location": "הוסף מיקום", - "add_a_name": "הוסף שם", - "add_a_title": "הוסף כותרת", - "add_exclusion_pattern": "הוסף דפוס החרגה", - "add_import_path": "הוסף נתיב יבוא", - "add_location": "הוסף מיקום", - "add_more_users": "הוסף עוד משתמשים", - "add_partner": "הוסף שותף", - "add_path": "הוסף נתיב", - "add_photos": "הוסף תמונות", - "add_to": "הוסף ל..", - "add_to_album": "הוסף לאלבום", - "add_to_shared_album": "הוסף לאלבום משותף", + "add": "הוספה", + "add_a_description": "הוספת תיאור", + "add_a_location": "הוספת מיקום", + "add_a_name": "הוספת שם", + "add_a_title": "הוספת כותרת", + "add_exclusion_pattern": "הוספת דפוס החרגה", + "add_import_path": "הוספת נתיב יבוא", + "add_location": "הוספת מיקום", + "add_more_users": "הוספת עוד משתמשים", + "add_partner": "הוספת שותף", + "add_path": "הוספת נתיב", + "add_photos": "הוספת תמונות", + "add_to": "הוספה ל…", + "add_to_album": "הוספה לאלבום", + "add_to_shared_album": "הוספה לאלבום משותף", "add_url": "הוספת קישור", "added_to_archive": "נוסף לארכיון", "added_to_favorites": "נוסף למועדפים", "added_to_favorites_count": "{count, number} נוספו למועדפים", "admin": { - "add_exclusion_pattern_description": "הוסף דפוסי החרגה. נתמכת התאמת דפוסים באמצעות *, ** ו-?. כדי להתעלם מכל הקבצים בתיקיה כלשהי בשם \"Raw\", השתמש ב \"**/Raw/**\". כדי להתעלם מכל הקבצים המסתיימים ב \"tif.\", השתמש ב \"tif.*/**\". כדי להתעלם מנתיב מוחלט, השתמש ב \"**/נתיב/להתעלמות\".", - "asset_offline_description": "נכס ספרייה חיצונית זה לא נמצא יותר בדיסק והועבר לאשפה. אם הקובץ הועבר מתוך הספרייה, בדוק את ציר הזמן שלך עבור הנכס המקביל החדש. כדי לשחזר נכס זה, נא לוודא ש-Immich יכול לגשת אל נתיב הקובץ למטה וסרוק מחדש את הספרייה.", + "add_exclusion_pattern_description": "הוספת דפוסי החרגה. נתמכת התאמת דפוסים באמצעות *, ** ו-?. כדי להתעלם מכל הקבצים בתיקיה כלשהי בשם \"Raw\", יש להשתמש ב \"**/Raw/**\". כדי להתעלם מכל הקבצים המסתיימים ב \"tif.\", יש להשתמש ב \"tif.*/**\". כדי להתעלם מנתיב מוחלט, יש להשתמש ב \"**/נתיב/להתעלמות\".", + "asset_offline_description": "נכס ספרייה חיצונית זה לא נמצא יותר בדיסק והועבר לאשפה. אם הקובץ הועבר מתוך הספרייה, נא לבדוק את ציר הזמן שלך עבור הנכס המקביל החדש. כדי לשחזר נכס זה, נא לוודא ש-Immich יכול לגשת אל נתיב הקובץ למטה ולסרוק מחדש את הספרייה.", "authentication_settings": "הגדרות התחברות", - "authentication_settings_description": "נהל סיסמה, OAuth, והגדרות התחברות אחרות", + "authentication_settings_description": "ניהול סיסמה, OAuth, והגדרות התחברות אחרות", "authentication_settings_disable_all": "האם ברצונך להשבית את כל שיטות ההתחברות? כניסה למערכת תהיה מושבתת לחלוטין.", - "authentication_settings_reenable": "כדי לאפשר מחדש, השתמש בפקודת שרת.", + "authentication_settings_reenable": "כדי לאפשר מחדש, נא להשתמש בפקודת שרת.", "background_task_job": "משימות רקע", "backup_database": "גיבוי מסד נתונים", "backup_database_enable_description": "אפשר גיבויי מסד נתונים", "backup_keep_last_amount": "כמות של גיבויים קודמים שיש לשמור", "backup_settings": "הגדרות גיבוי", - "backup_settings_description": "נהל הגדרות גיבוי מסד נתונים", - "check_all": "סמן הכל", + "backup_settings_description": "ניהול הגדרות גיבוי מסד נתונים", + "check_all": "סימון הכל", + "cleanup": "ניקוי", "cleared_jobs": "נוקו משימות עבור: {job}", "config_set_by_file": "התצורה מוגדרת כעת על ידי קובץ תצורה", "confirm_delete_library": "האם את/ה בטוח/ה שברצונך למחוק את הספרייה {library}?", @@ -76,7 +77,7 @@ "image_resolution": "רזולוציה", "image_resolution_description": "רזולוציות גבוהות יותר יכולות לשמר פרטים רבים יותר אך לוקחות זמן רב יותר לקידוד, יש להן גדלי קבצים גדולים יותר ויכולות להפחית את תגובתיות היישום.", "image_settings": "הגדרות תמונה", - "image_settings_description": "נהל את האיכות והרזולוציה של תמונות שנוצרו", + "image_settings_description": "ניהול האיכות והרזולוציה של תמונות שנוצרו", "image_thumbnail_description": "תמונה ממוזערת קטנה עם מטא-נתונים שהוסרו, משמשת בעת צפייה בקבוצות של תמונות כמו ציר הזמן הראשי", "image_thumbnail_quality_description": "איכות תמונה ממוזערת בין 1-100. גבוה יותר הוא טוב יותר, אבל מייצר קבצים גדולים יותר ויכול להפחית את תגובתיות היישום.", "image_thumbnail_title": "הגדרות תמונה ממוזערת", @@ -95,8 +96,8 @@ "library_scanning_description": "הגדר סריקת ספרייה תקופתית", "library_scanning_enable_description": "אפשר סריקת ספרייה תקופתית", "library_settings": "ספרייה חיצונית", - "library_settings_description": "נהל הגדרות ספרייה חיצונית", - "library_tasks_description": "ביצוע משימות ספרייה", + "library_settings_description": "ניהול הגדרות ספרייה חיצונית", + "library_tasks_description": "סרוק ספריות חיצוניות עבור נכסים חדשים ו/או שהשתנו", "library_watching_enable_description": "עקוב אחר שינויי קבצים בספריות חיצוניות", "library_watching_settings": "צפיית ספרייה (ניסיוני)", "library_watching_settings_description": "עקוב אוטומטית אחר שינויי קבצים", @@ -126,33 +127,35 @@ "machine_learning_min_recognized_faces": "מינימום פנים מזוהים", "machine_learning_min_recognized_faces_description": "המספר המינימלי של פנים מזוהים ליצירת אדם. הגדלת ערך זה הופכת את זיהוי הפנים למדויק יותר בעלות של הגברת הסיכוי שלא יוקצו פנים לאדם.", "machine_learning_settings": "הגדרות למידת מכונה", - "machine_learning_settings_description": "נהל את התכונות וההגדרות של למידת המכונה", + "machine_learning_settings_description": "ניהול התכונות וההגדרות של למידת המכונה", "machine_learning_smart_search": "חיפוש חכם", - "machine_learning_smart_search_description": "חפש תמונות באופן סמנטי באמצעות הטמעות של CLIP", + "machine_learning_smart_search_description": "חיפוש תמונות באופן סמנטי באמצעות הטמעות של CLIP", "machine_learning_smart_search_enabled": "אפשר חיפוש חכם", "machine_learning_smart_search_enabled_description": "אם מושבת, תמונות לא יקודדו לחיפוש חכם.", - "machine_learning_url_description": "כתובת האתר של שרת למידת המכונה. אם ניתנת יותר מכתובת אחת, כל שרת ינסה בתורו עד אשר יענה בחיוב, בסדר התחלתי.", - "manage_concurrency": "נהל בו-זמניות", - "manage_log_settings": "נהל הגדרות רישום ביומן", + "machine_learning_url_description": "כתובת ה-URL של שרת למידת המכונה. אם ניתנת יותר מכתובת URL אחת, כל שרת ינוסה ניסיון אחד בכל פעם עד שאחד מהם יגיב בהצלחה, לפי הסדר מהראשון עד האחרון. שרתים שלא מגיבים יוזנחו זמנית עד שיחזרו להיות מקוונים.", + "manage_concurrency": "ניהול בו-זמניות", + "manage_log_settings": "ניהול הגדרות רישום ביומן", "map_dark_style": "עיצוב כהה", "map_enable_description": "אפשר תכונות מפה", "map_gps_settings": "הגדרות מפה & GPS", - "map_gps_settings_description": "נהל הגדרות מפה & GPS (קידוד גאוגרפי הפוך)", + "map_gps_settings_description": "ניהול הגדרות מפה & GPS (קידוד גאוגרפי הפוך)", "map_implications": "תכונת המפה מסתמכת על שירות אריח חיצוני (tiles.immich.cloud)", "map_light_style": "עיצוב בהיר", - "map_manage_reverse_geocoding_settings": "נהל הגדרות קידוד גאוגרפי הפוך", + "map_manage_reverse_geocoding_settings": "ניהול הגדרות קידוד גאוגרפי הפוך", "map_reverse_geocoding": "קידוד גיאוגרפי הפוך", "map_reverse_geocoding_enable_description": "אפשר קידוד גיאוגרפי הפוך", "map_reverse_geocoding_settings": "הגדרות קידוד גיאוגרפי הפוך", "map_settings": "מפה", - "map_settings_description": "נהל הגדרות מפה", + "map_settings_description": "ניהול הגדרות מפה", "map_style_description": "כתובת אתר לערכת נושא של מפה style.json", + "memory_cleanup_job": "ניקוי זיכרון", + "memory_generate_job": "יצירת זיכרון", "metadata_extraction_job": "חלץ מטא-נתונים", "metadata_extraction_job_description": "חלץ מידע מטא-נתונים מכל נכס, כגון GPS, פנים ורזולוציה", "metadata_faces_import_setting": "אפשר יבוא פנים", "metadata_faces_import_setting_description": "יבא פנים מנתוני EXIF של תמונה ומקבצים נלווים", "metadata_settings": "הגדרות מטא-נתונים", - "metadata_settings_description": "נהל הגדרות מטא-נתונים", + "metadata_settings_description": "ניהול הגדרות מטא-נתונים", "migration_job": "העברה", "migration_job_description": "העבר תמונות ממוזערות של נכסים ופנים למבנה התיקיות העדכני ביותר", "no_paths_added": "לא נוספו נתיבים", @@ -175,7 +178,7 @@ "notification_email_username_description": "שם משתמש לשימוש בעת אימות עם שרת הדוא\"ל", "notification_enable_email_notifications": "אפשר התראות דוא\"ל", "notification_settings": "הגדרות התראות", - "notification_settings_description": "נהל הגדרות התראות, כולל דוא\"ל", + "notification_settings_description": "ניהול הגדרות התראות, כולל דוא\"ל", "oauth_auto_launch": "הפעלה אוטומטית", "oauth_auto_launch_description": "התחל את זרימת ההתחברות של OAuth באופן אוטומטי עם הניווט לדף ההתחברות", "oauth_auto_register": "רישום אוטומטי", @@ -192,7 +195,7 @@ "oauth_profile_signing_algorithm_description": "אלגוריתם המשמש לחתימה על פרופיל המשתמש.", "oauth_scope": "רמת הרשאה", "oauth_settings": "OAuth", - "oauth_settings_description": "נהל הגדרות התחברות עם OAuth", + "oauth_settings_description": "ניהול הגדרות התחברות עם OAuth", "oauth_settings_more_details": "למידע נוסף אודות תכונה זו, בדוק את התיעוד.", "oauth_signing_algorithm": "אלגוריתם חתימה", "oauth_storage_label_claim": "דרישת תווית אחסון", @@ -205,7 +208,7 @@ "offline_paths_description": "תוצאות אלו עשויות להיות עקב מחיקה ידנית של קבצים שאינם חלק מספרייה חיצונית.", "password_enable_description": "התחבר עם דוא\"ל וסיסמה", "password_settings": "סיסמת התחברות", - "password_settings_description": "נהל הגדרות סיסמת התחברות", + "password_settings_description": "ניהול הגדרות סיסמת התחברות", "paths_validated_successfully": "כל הנתיבים אומתו בהצלחה", "person_cleanup_job": "ניקוי אדם", "quota_size_gib": "גודל מכסה (GiB)", @@ -219,14 +222,14 @@ "reset_settings_to_default": "אפס הגדרות לברירת המחדל", "reset_settings_to_recent_saved": "אפס הגדרות להגדרות שנשמרו לאחרונה", "scanning_library": "סורק ספרייה", - "search_jobs": "חיפוש עבודות...", + "search_jobs": "חיפוש עבודות…", "send_welcome_email": "שלח דוא\"ל ברוכים הבאים", "server_external_domain_settings": "דומיין חיצוני", "server_external_domain_settings_description": "דומיין עבור קישורים משותפים ציבוריים, כולל http(s)://", "server_public_users": "משתמשים ציבוריים", - "server_public_users_description": "כל המשתמשים (שם ודוא\"ל) מופיעים בעת הוספת משתמש לאלבומים משותפים. כאשר התכונה מושבתת, רשימת המשתמשים תהיה זמינה רק למשתמשים בעלי הרשאות מנהל.", + "server_public_users_description": "כל המשתמשים (שם ודוא\"ל) מופיעים בעת הוספת משתמש לאלבומים משותפים. כאשר התכונה מושבתת, רשימת המשתמשים תהיה זמינה רק למשתמשים בעלי הרשאות ניהול.", "server_settings": "הגדרות שרת", - "server_settings_description": "נהל הגדרות שרת", + "server_settings_description": "ניהול הגדרות שרת", "server_welcome_message": "הודעת פתיחה", "server_welcome_message_description": "הודעה שמוצגת במסך ההתחברות.", "sidecar_job": "מטא-נתונים נלווים", @@ -240,13 +243,13 @@ "storage_template_hash_verification_enabled_description": "מאפשר אימות גיבוב, אל תשבית/י זאת אלא אם כן את/ה בטוח/ה בהשלכות", "storage_template_migration": "העברת תבנית אחסון", "storage_template_migration_description": "החל את ה{template} הנוכחית על נכסים שהועלו בעבר", - "storage_template_migration_info": "שינויים בתבנית יחולו רק על נכסים חדשים. כדי להחיל באופן רטרואקטיבי את התבנית על נכסים שהועלו בעבר, הפעל את {job}.", + "storage_template_migration_info": "תבנית האחסון תמיר את כל ההרחבות לאותיות קטנות. שינויים בתבנית יחולו רק על נכסים חדשים. כדי להחיל באופן רטרואקטיבי את התבנית על נכסים שהועלו בעבר, הפעל את {job}.", "storage_template_migration_job": "משימת העברת תבנית אחסון", "storage_template_more_details": "לפרטים נוספים אודות תכונה זו, עיין בתבנית האחסון ובהשלכותיה", "storage_template_onboarding_description": "כאשר מופעלת, תכונה זו תארגן אוטומטית קבצים בהתבסס על תבנית שהמשתמש הגדיר. עקב בעיות יציבות התכונה כבויה כברירת מחדל. למידע נוסף, נא לראות את התיעוד.", "storage_template_path_length": "מגבלת אורך נתיב משוערת: {length, number}/{limit, number}", "storage_template_settings": "תבנית אחסון", - "storage_template_settings_description": "נהל את מבנה התיקיות ואת שם הקובץ של נכס ההעלאה", + "storage_template_settings_description": "ניהול מבנה התיקיות ואת שם הקובץ של נכס ההעלאה", "storage_template_user_label": "{label} היא תווית האחסון של המשתמש", "system_settings": "הגדרות מערכת", "tag_cleanup_job": "ניקוי תגים", @@ -255,15 +258,15 @@ "template_email_invite_album": "תבנית הזמנת אלבום", "template_email_preview": "תצוגה מקדימה", "template_email_settings": "תבניות דוא\"ל", - "template_email_settings_description": "נהל תבניות התראת דוא\"ל מותאמות אישית", + "template_email_settings_description": "ניהול תבניות התראת דוא\"ל מותאמות אישית", "template_email_update_album": "עדכון תבנית אלבום", "template_email_welcome": "תבנית דוא\"ל ברוכים הבאים", "template_settings": "תבניות התראה", - "template_settings_description": "נהל תבניות מותאמות אישית עבור התראות.", + "template_settings_description": "ניהול תבניות מותאמות אישית עבור התראות.", "theme_custom_css_settings": "CSS בהתאמה אישית", "theme_custom_css_settings_description": "גיליונות סגנון מדורגים (CSS) מאפשרים התאמה אישית של העיצוב של Immich.", "theme_settings": "הגדרות ערכת נושא", - "theme_settings_description": "נהל התאמה אישית של ממשק האינטרנט של Immich", + "theme_settings_description": "ניהול התאמה אישית של ממשק האינטרנט של Immich", "these_files_matched_by_checksum": "קבצים אלה תואמים לפי סיכומי הביקורת שלהם", "thumbnail_generation_job": "צור תמונות ממוזערות", "thumbnail_generation_job_description": "יוצר תמונות ממוזערות גדולות, קטנות ומטושטשות עבור כל נכס, כמו גם תמונות ממוזערות עבור כל אדם", @@ -299,7 +302,7 @@ "transcoding_max_b_frames": "B-פריימים מרביים", "transcoding_max_b_frames_description": "ערכים גבוהים יותר משפרים את יעילות הדחיסה, אך מאטים את הקידוד. ייתכן שלא יהיה תואם עם האצת חומרה במכשירים ישנים יותר. 0 משבית את B-פריימים, בעוד ש1- מגדיר את הערך זה באופן אוטומטי.", "transcoding_max_bitrate": "קצב סיביות מרבי", - "transcoding_max_bitrate_description": "קביעת קצב סיביות מרבי יכולה להפוך את גדלי הקבצים לצפויים יותר בעלות קלה לאיכות. ב-720p, ערכים טיפוסיים הם 2600k עבור VP9 או HEVC, או 4500k עבור H.264. מושבת אם מוגדר ל-0.", + "transcoding_max_bitrate_description": "קביעת קצב סיביות מרבי יכולה להפוך את גדלי הקבצים לצפויים יותר בעלות קלה לאיכות. ב-720p, ערכים טיפוסיים הם 2600 kbit/s עבור VP9 או HEVC, או 4500 kbit/s עבור H.264. מושבת אם מוגדר ל-0.", "transcoding_max_keyframe_interval": "מרווח תמונת מפתח מרבי", "transcoding_max_keyframe_interval_description": "מגדיר את מרחק הפריימים המרבי בין תמונות מפתח. ערכים נמוכים גורעים את יעילות הדחיסה, אך משפרים את זמני החיפוש ועשויים לשפר את האיכות בסצנות עם תנועה מהירה. 0 מגדיר ערך זה באופן אוטומטי.", "transcoding_optimal_description": "סרטונים גבוהים מרזולוציית היעד או לא בפורמט מקובל", @@ -313,10 +316,10 @@ "transcoding_reference_frames_description": "מספר הפריימים לייחוס בעת דחיסה של פריים נתון. ערכים גבוהים יותר משפרים את יעילות הדחיסה, אך מאטים את הקידוד. 0 מגדיר את הערך זה באופן אוטומטי.", "transcoding_required_description": "רק סרטונים שאינם בפורמט מקובל", "transcoding_settings": "הגדרות המרת קידוד סרטונים", - "transcoding_settings_description": "נהל אילו סרטונים להמיר וכיצד לעבד אותם", + "transcoding_settings_description": "ניהול אילו סרטונים להמיר וכיצד לעבד אותם", "transcoding_target_resolution": "רזולוציה יעד", "transcoding_target_resolution_description": "רזולוציות גבוהות יותר יכולות לשמר פרטים רבים יותר אך לוקחות זמן רב יותר לקידוד, יש להן גדלי קבצים גדולים יותר, ויכולות להפחית את תגובתיות היישום.", - "transcoding_temporal_aq": "Temporal AQ", + "transcoding_temporal_aq": "AQ מבוסס זמן", "transcoding_temporal_aq_description": "חל רק על NVENC. מגביר את האיכות של סצנות עם רמת פירוט גבוהה בהילוך איטי. ייתכן שלא יהיה תואם למכשירים ישנים יותר.", "transcoding_threads": "תהליכונים", "transcoding_threads_description": "ערכים גבוהים יותר מובילים לקידוד מהיר יותר, אך משאירים פחות מקום לשרת לעבד משימות אחרות בעודו פעיל. ערך זה לא אמור להיות יותר ממספר ליבות המעבד. ממקסם את הניצול אם מוגדר ל-0.", @@ -332,7 +335,7 @@ "trash_number_of_days": "מספר הימים", "trash_number_of_days_description": "מספר הימים לשמירה על הנכסים באשפה לפני הסרתם לצמיתות", "trash_settings": "הגדרות האשפה", - "trash_settings_description": "נהל את הגדרות האשפה", + "trash_settings_description": "ניהול הגדרות האשפה", "untracked_files": "קבצים ללא מעקב", "untracked_files_description": "קבצים אלה אינם נמצאים במעקב של היישום. הם יכולים להיות תוצאות של העברות כושלות, העלאות שנקטעו, או שנותרו מאחור בגלל שיבוש בתוכנה", "user_cleanup_job": "ניקוי משתמשים", @@ -347,7 +350,7 @@ "user_restore_description": "החשבון של {user} ישוחזר.", "user_restore_scheduled_removal": "שחזר משתמש - מחיקה מתוזמנת ב-{date, date, long}", "user_settings": "הגדרות משתמש", - "user_settings_description": "נהל הגדרות משתמש", + "user_settings_description": "ניהול הגדרות משתמש", "user_successfully_removed": "המשתמש {email} הוסר בהצלחה.", "version_check_enabled_description": "אפשר בדיקת גרסה", "version_check_implications": "תכונת בדיקת הגרסה מסתמכת על תקשורת תקופתית עם github.com", @@ -391,6 +394,7 @@ "allow_edits": "אפשר עריכות", "allow_public_user_to_download": "אפשר למשתמש ציבורי להוריד", "allow_public_user_to_upload": "אפשר למשתמש ציבורי להעלות", + "alt_text_qr_code": "תמונת קוד QR", "anti_clockwise": "נגד כיוון השעון", "api_key": "מפתח API", "api_key_description": "הערך הזה יוצג רק פעם אחת. נא לוודא שהעתקת אותו לפני סגירת החלון.", @@ -406,17 +410,17 @@ "are_these_the_same_person": "האם אלה אותו האדם?", "are_you_sure_to_do_this": "האם את/ה בטוח/ה שברצונך לעשות את זה?", "asset_added_to_album": "נוסף לאלבום", - "asset_adding_to_album": "מוסיף לאלבום...", + "asset_adding_to_album": "מוסיף לאלבום…", "asset_description_updated": "תיאור הנכס עודכן", "asset_filename_is_offline": "הנכס {filename} אינו מקוון", "asset_has_unassigned_faces": "לנכס יש פנים שלא הוקצו", - "asset_hashing": "מגבב...", + "asset_hashing": "מגבב…", "asset_offline": "נכס לא מקוון", - "asset_offline_description": "הנכס החיצוני הזה כבר לא נמצא בדיסק. אנא צור קשר עם מנהל Immich שלך לקבלת עזרה.", + "asset_offline_description": "הנכס החיצוני הזה כבר לא נמצא בדיסק. נא ליצור קשר עם מנהל Immich שלך לקבלת עזרה.", "asset_skipped": "דילג", "asset_skipped_in_trash": "באשפה", "asset_uploaded": "הועלה", - "asset_uploading": "מעלה...", + "asset_uploading": "מעלה…", "assets": "נכסים", "assets_added_count": "{count, plural, one {נוסף נכס #} other {נוספו # נכסים}}", "assets_added_to_album_count": "{count, plural, one {נוסף נכס #} other {נוספו # נכסים}} לאלבום", @@ -437,7 +441,7 @@ "birthdate_set_description": "תאריך לידה משמש לחישוב הגיל של האדם הזה בזמן תצלום.", "blurred_background": "רקע מטושטש", "bugs_and_feature_requests": "באגים & בקשות לתכונות", - "build": "Build", + "build": "גרסת בנייה", "build_image": "גרסת תוכנה", "bulk_delete_duplicates_confirmation": "האם את/ה בטוח/ה שברצונך למחוק בכמות גדולה {count, plural, one {נכס # כפול} other {# נכסים כפולים}}? זה ישמור על הנכס הכי גדול של כל קבוצה וימחק לצמיתות את כל שאר הכפילויות. את/ה לא יכול/ה לבטל את הפעולה הזו!", "bulk_keep_duplicates_confirmation": "האם את/ה בטוח/ה שברצונך להשאיר {count, plural, one {נכס # כפול} other {# נכסים כפולים}}? זה יפתור את כל הקבוצות הכפולות מבלי למחוק דבר.", @@ -480,7 +484,8 @@ "comments_and_likes": "תגובות & לייקים", "comments_are_disabled": "תגובות מושבתות", "confirm": "אישור", - "confirm_admin_password": "אשר סיסמת מנהל", + "confirm_admin_password": "אישור סיסמת מנהל", + "confirm_delete_face": "האם את/ה בטוח/ה שברצונך למחוק את הפנים של {name} מהנכס?", "confirm_delete_shared_link": "האם את/ה בטוח/ה שברצונך למחוק את הקישור המשותף הזה?", "confirm_keep_this_delete_others": "כל שאר הנכסים בערימה יימחקו למעט נכס זה. האם את/ה בטוח/ה שברצונך להמשיך?", "confirm_password": "אשר סיסמה", @@ -533,6 +538,7 @@ "delete_album": "מחק אלבום", "delete_api_key_prompt": "האם את/ה בטוח/ה שברצונך למחוק מפתח ה-API הזה?", "delete_duplicates_confirmation": "האם את/ה בטוח/ה שברצונך למחוק לצמיתות את הכפילויות האלה?", + "delete_face": "מחק פנים", "delete_key": "מחק מפתח", "delete_library": "מחק ספרייה", "delete_link": "מחק קישור", @@ -548,7 +554,7 @@ "direction": "כיוון", "disabled": "מושבת", "disallow_edits": "אל תאפשר עריכות", - "discord": "Discord", + "discord": "דיסקורד", "discover": "גילוי", "dismiss_all_errors": "התעלמות מכל השגיאות", "dismiss_error": "התעלמות מהשגיאה", @@ -563,7 +569,7 @@ "download_include_embedded_motion_videos": "סרטונים מוטמעים", "download_include_embedded_motion_videos_description": "כלול סרטונים מוטעמים בתמונות עם תנועה כקובץ נפרד", "download_settings": "הורדה", - "download_settings_description": "נהל הגדרות הקשורות להורדת נכסים", + "download_settings_description": "ניהול הגדרות הקשורות להורדת נכסים", "downloading": "מוריד", "downloading_asset_filename": "מוריד נכס {filename}", "drop_files_to_upload": "שחרר קבצים בכל מקום כדי להעלות", @@ -600,6 +606,7 @@ "enabled": "מופעל", "end_date": "תאריך סיום", "error": "שגיאה", + "error_delete_face": "שגיאה במחיקת פנים מנכס", "error_loading_image": "שגיאה בטעינת התמונה", "error_title": "שגיאה - משהו השתבש", "errors": { @@ -748,7 +755,7 @@ "favorites": "מועדפים", "feature_photo_updated": "תמונה מייצגת עודכנה", "features": "תכונות", - "features_setting_description": "נהל את תכונות היישום", + "features_setting_description": "ניהול תכונות היישום", "file_name": "שם הקובץ", "file_name_or_extension": "שם קובץ או סיומת", "filename": "שם קובץ", @@ -766,8 +773,10 @@ "go_to_folder": "עבור לתיקיה", "go_to_search": "עבור לחיפוש", "group_albums_by": "קבץ אלבומים לפי..", + "group_country": "קבץ לפי מדינה", "group_no": "אין קיבוץ", "group_owner": "קבץ לפי בעלים", + "group_places_by": "קבץ מקומות לפי...", "group_year": "קבץ לפי שנה", "has_quota": "יש מכסה", "hi_user": "היי {name}, ({email})", @@ -800,6 +809,7 @@ "include_shared_albums": "כלול אלבומים משותפים", "include_shared_partner_assets": "כלול נכסי שותף משותפים", "individual_share": "שיתוף יחיד", + "individual_shares": "שיתופים בודדים", "info": "מידע", "interval": { "day_at_onepm": "כל יום בשעה 13:00", @@ -822,6 +832,7 @@ "latest_version": "גרסה עדכנית ביותר", "latitude": "קו רוחב", "leave": "לעזוב", + "lens_model": "דגם עדשה", "let_others_respond": "אפשר לאחרים להגיב", "level": "רמה", "library": "ספרייה", @@ -849,13 +860,13 @@ "loop_videos_description": "אפשר הפעלה חוזרת אוטומטית של סרטון במציג הפרטים.", "main_branch_warning": "את/ה משתמש/ת בגרסת פיתוח; אנחנו ממליצים בחום להשתמש בגרסה יציבה!", "make": "תוצרת", - "manage_shared_links": "נהל קישורים משותפים", - "manage_sharing_with_partners": "נהל שיתוף עם שותפים", - "manage_the_app_settings": "נהל את הגדרות האפליקציה", - "manage_your_account": "נהל את החשבון שלך", - "manage_your_api_keys": "נהל את מפתחות ה API שלך", - "manage_your_devices": "נהל את המכשירים המחוברים שלך", - "manage_your_oauth_connection": "נהל את חיבור ה-OAuth שלך", + "manage_shared_links": "ניהול קישורים משותפים", + "manage_sharing_with_partners": "ניהול שיתוף עם שותפים", + "manage_the_app_settings": "ניהול הגדרות האפליקציה", + "manage_your_account": "ניהול החשבון שלך", + "manage_your_api_keys": "ניהול מפתחות ה API שלך", + "manage_your_devices": "ניהול המכשירים המחוברים שלך", + "manage_your_oauth_connection": "ניהול חיבור ה-OAuth שלך", "map": "מפה", "map_marker_for_images": "סמן מפה לתמונות שצולמו ב{city}, {country}", "map_marker_with_image": "סמן מפה עם תמונה", @@ -863,7 +874,7 @@ "matches": "התאמות", "media_type": "סוג מדיה", "memories": "זכרונות", - "memories_setting_description": "נהל מה שאת/ה רואה בזכרונות שלך", + "memories_setting_description": "ניהול מה שאת/ה רואה בזכרונות שלך", "memory": "זיכרון", "memory_lane_title": "משעול הזיכרונות {title}", "menu": "תפריט", @@ -880,6 +891,7 @@ "month": "חודש", "more": "עוד", "moved_to_trash": "הועבר לאשפה", + "mute_memories": "השתקת זיכרונות", "my_albums": "האלבומים שלי", "name": "שם", "name_or_nickname": "שם או כינוי", @@ -915,7 +927,7 @@ "notes": "הערות", "notification_toggle_setting_description": "אפשר התראות דוא\"ל", "notifications": "התראות", - "notifications_setting_description": "נהל התראות", + "notifications_setting_description": "ניהול התראות", "oauth": "OAuth", "official_immich_resources": "משאבי Immich רשמיים", "offline": "לא מקוון", @@ -984,6 +996,7 @@ "pick_a_location": "בחר מיקום", "place": "מקום", "places": "מקומות", + "places_count": "{count, plural, one {מקום {count, number}} other {{count, number} מקומות}}", "play": "נגן", "play_memories": "נגן זכרונות", "play_motion_photo": "הפעל תמונה עם תנועה", @@ -1071,6 +1084,8 @@ "removed_from_archive": "הוסר מארכיון", "removed_from_favorites": "הוסר ממועדפים", "removed_from_favorites_count": "{count, plural, other {הוסרו #}} מהמועדפים", + "removed_memory": "זיכרון הוסר", + "removed_photo_from_memory": "התמונה הוסרה מהזיכרון", "removed_tagged_assets": "תג הוסר מ{count, plural, one {נכס #} other {# נכסים}}", "rename": "שנה שם", "repair": "תיקון", @@ -1079,6 +1094,7 @@ "repository": "מאגר", "require_password": "דרוש סיסמה", "require_user_to_change_password_on_first_login": "דרוש מהמשתמש לשנות סיסמה בכניסה הראשונה", + "rescan": "סרוק מחדש", "reset": "איפוס", "reset_password": "איפוס סיסמה", "reset_people_visibility": "אפס את נראות האנשים", @@ -1104,27 +1120,31 @@ "scan_library": "סרוק", "scan_settings": "הגדרות סריקה", "scanning_for_album": "סורק אחר אלבום...", - "search": "חפש", - "search_albums": "חפש אלבומים", - "search_by_context": "חפש לפי הקשר", + "search": "חיפוש", + "search_albums": "חיפוש אלבומים", + "search_by_context": "חיפוש לפי הקשר", + "search_by_description": "חיפוש לפי תיאור", + "search_by_description_example": "יום טיול בסאפה", "search_by_filename": "חיפוש לפי שם קובץ או סיומת", "search_by_filename_example": "לדוגמא IMG_1234.JPG או PNG", - "search_camera_make": "חפש תוצרת מצלמה...", - "search_camera_model": "חפש דגם מצלמה...", - "search_city": "חפש עיר...", - "search_country": "חפש ארץ...", - "search_for_existing_person": "חפש אדם קיים", + "search_camera_make": "חיפוש תוצרת המצלמה...", + "search_camera_model": "חפש דגם המצלמה...", + "search_city": "חיפוש עיר...", + "search_country": "חיפוש ארץ...", + "search_for": "חיפוש", + "search_for_existing_person": "חיפוש אדם קיים", "search_no_people": "אין אנשים", "search_no_people_named": "אין אנשים בשם \"{name}\"", "search_options": "אפשרויות חיפוש", - "search_people": "חפש אנשים", - "search_places": "חפש מקומות", + "search_people": "חיפוש אנשים", + "search_places": "חיפוש מקומות", + "search_rating": "חיפוש לפי דירוג...", "search_settings": "הגדרות חיפוש", - "search_state": "חפש מדינה...", + "search_state": "חיפוש מדינה...", "search_tags": "חיפוש תגים...", - "search_timezone": "חפש אזור זמן...", + "search_timezone": "חיפוש אזור זמן...", "search_type": "סוג חיפוש", - "search_your_photos": "חפש בתמונות שלך", + "search_your_photos": "חיפוש בתמונות שלך", "searching_locales": "מחפש אזורי שפה...", "second": "שנייה", "see_all_people": "ראה את כל האנשים", @@ -1165,6 +1185,7 @@ "shared_from_partner": "תמונות מאת {partner}", "shared_link_options": "אפשרויות קישור משותף", "shared_links": "קישורים משותפים", + "shared_links_description": "שתף תמונות וסרטונים עם קישור", "shared_photos_and_videos_count": "{assetCount, plural, other {# תמונות וסרטונים משותפים.}}", "shared_with_partner": "משותף עם {partner}", "sharing": "שיתוף", @@ -1187,6 +1208,7 @@ "show_person_options": "הצג אפשרויות אדם", "show_progress_bar": "הצג סרגל התקדמות", "show_search_options": "הצג אפשרויות חיפוש", + "show_shared_links": "הצג קישורים משותפים", "show_slideshow_transition": "הצג מעבר מצגת", "show_supporter_badge": "תג תומך", "show_supporter_badge_description": "הצג תג תומך", @@ -1215,7 +1237,7 @@ "stack_select_one_photo": "בחר תמונה ראשית אחת עבור הערימה", "stack_selected_photos": "צור ערימת תמונות נבחרות", "stacked_assets_count": "{count, plural, one {נכס # נערם} other {# נכסים נערמו}}", - "stacktrace": "Stacktrace", + "stacktrace": "Stack trace", "start": "התחל", "start_date": "תאריך התחלה", "state": "מדינה", @@ -1240,6 +1262,7 @@ "tag_created": "נוצר תג: {tag}", "tag_feature_description": "עיון בתמונות וסרטונים שקובצו על ידי נושאי תג לוגיים", "tag_not_found_question": "לא מצליח למצוא תג? צור תג חדש", + "tag_people": "תייג אנשים", "tag_updated": "תג מעודכן: {tag}", "tagged_assets": "תויגו {count, plural, one {נכס #} other {# נכסים}}", "tags": "תגים", @@ -1274,11 +1297,13 @@ "unfavorite": "לא מועדף", "unhide_person": "בטל הסתרת אדם", "unknown": "לא ידוע", + "unknown_country": "מדינה לא ידועה", "unknown_year": "שנה לא ידועה", "unlimited": "בלתי מוגבל", "unlink_motion_video": "בטל קישור סרטון תנועה", "unlink_oauth": "בטל קישור OAuth", "unlinked_oauth_account": "בוטל קישור חשבון OAuth", + "unmute_memories": "בטל השתקת זיכרונות", "unnamed_album": "אלבום ללא שם", "unnamed_album_delete_confirmation": "את/ה בטוח/ה שברצונך למחוק את האלבום הזה?", "unnamed_share": "שיתוף ללא שם", @@ -1307,7 +1332,7 @@ "user_id": "מזהה משתמש", "user_liked": "{user} אהב את {type, select, photo {התמונה הזאת} video {הסרטון הזה} asset {הנכס הזה} other {זה}}", "user_purchase_settings": "רכישה", - "user_purchase_settings_description": "נהל את הרכישה שלך", + "user_purchase_settings_description": "ניהול הרכישה שלך", "user_role_set": "הגדר את {user} בתור {role}", "user_usage_detail": "פרטי השימוש של המשתמש", "user_usage_stats": "סטטיסטיקות שימוש בחשבון", @@ -1332,6 +1357,7 @@ "view_all": "הצג הכל", "view_all_users": "הצג את כל המשתמשים", "view_in_timeline": "ראה בציר הזמן", + "view_link": "הצג קישור", "view_links": "הצג קישורים", "view_name": "הצג", "view_next_asset": "הצג את הנכס הבא", @@ -1348,4 +1374,4 @@ "yes": "כן", "you_dont_have_any_shared_links": "אין לך קישורים משותפים", "zoom_image": "זום לתמונה" -} +} \ No newline at end of file diff --git a/i18n/hi.json b/i18n/hi.json index fdc79c15eb..c2534fed72 100644 --- a/i18n/hi.json +++ b/i18n/hi.json @@ -13,14 +13,14 @@ "add_a_location": "एक स्थान जोड़ें", "add_a_name": "नाम जोड़ें", "add_a_title": "एक शीर्षक जोड़ें", - "add_exclusion_pattern": "निषेध उदाहरण जोड़ें", + "add_exclusion_pattern": "अपवाद उदाहरण जोड़ें", "add_import_path": "आयात पथ जोड़ें", "add_location": "स्थान जोड़ें", "add_more_users": "अधिक उपयोगकर्ता जोड़ें", "add_partner": "जोड़ीदार जोड़ें", "add_path": "पथ जोड़ें", "add_photos": "फ़ोटो जोड़ें", - "add_to": "इसमें जोड़ें..।", + "add_to": "इसमें जोड़ें…", "add_to_album": "एल्बम में जोड़ें", "add_to_shared_album": "साझा एल्बम में जोड़ें", "add_url": "URL जोड़ें", @@ -47,7 +47,7 @@ "exclusion_pattern_description": "Exclusion पैटर्न आपको अपनी लाइब्रेरी को स्कैन करते समय फ़ाइलों और फ़ोल्डरों को अनदेखा करने देता है। यह उपयोगी है यदि आपके पास ऐसे फ़ोल्डर हैं जिनमें ऐसी फ़ाइलें हैं जिन्हें आप आयात नहीं करना चाहते हैं, जैसे RAW फ़ाइलें।", "external_library_created_at": "बाहरी लाइब्रेरी ({date} को बनाई गई)", "external_library_management": "बाहरी लाइब्रेरी प्रबंधन", - "face_detection": "चेहरे का पहचान", + "face_detection": "मुख संशोधन", "face_detection_description": "मशीन लर्निंग का उपयोग करके संपत्तियों में चेहरों का पता लगाएं। वीडियो के लिए, केवल थंबनेल पर विचार किया जाता है। \"सभी\" परिसंपत्तियों को (पुनः) संसाधित करता है। \"लापता\" उन परिसंपत्तियों को कतारबद्ध करता है जिन्हें अभी तक संसाधित नहीं किया गया है। फेस डिटेक्शन पूरा होने के बाद पहचाने गए चेहरों को चेहरे की पहचान के लिए कतारबद्ध किया जाएगा, उन्हें मौजूदा या नए लोगों में समूहित किया जाएगा।", "facial_recognition_job_description": "समूह ने लोगों में चेहरों का पता लगाया। यह चरण फेस डिटेक्शन पूरा होने के बाद चलता है। \"सभी\" चेहरों को (पुनः) समूहित करता है। \"लापता\" कतार में वे चेहरे हैं जिनके लिए कोई व्यक्ति नियुक्त नहीं है।", "failed_job_command": "कार्य {job} के लिए आदेश {command} विफल", diff --git a/i18n/hr.json b/i18n/hr.json index b6d9c97748..8b8e909391 100644 --- a/i18n/hr.json +++ b/i18n/hr.json @@ -288,7 +288,7 @@ "transcoding_max_b_frames": "Maksimalni B-frameovi", "transcoding_max_b_frames_description": "Više vrijednosti poboljšavaju učinkovitost kompresije, ali usporavaju kodiranje. Možda nije kompatibilan s hardverskim ubrzanjem na starijim uređajima. 0 onemogućuje B-frameove, dok -1 automatski postavlja ovu vrijednost.", "transcoding_max_bitrate": "Maksimalne brzina prijenosa (bitrate)", - "transcoding_max_bitrate_description": "Postavljanje maksimalne brzine prijenosa može učiniti veličine datoteka predvidljivijima uz manji trošak za kvalitetu. Pri 720p, tipične vrijednosti su 2600k za VP9 ili HEVC ili 4500k za H.264. Onemogućeno ako je postavljeno na 0.", + "transcoding_max_bitrate_description": "Postavljanje maksimalne brzine prijenosa može učiniti veličine datoteka predvidljivijima uz manji trošak za kvalitetu. Pri 720p, tipične vrijednosti su 2600 kbit/s za VP9 ili HEVC ili 4500 kbit/s za H.264. Onemogućeno ako je postavljeno na 0.", "transcoding_max_keyframe_interval": "Maksimalni interval ključnih sličica", "transcoding_max_keyframe_interval_description": "Postavlja maksimalnu udaljenost slika između ključnih kadrova. Niže vrijednosti pogoršavaju učinkovitost kompresije, ali poboljšavaju vrijeme traženja i mogu poboljšati kvalitetu u scenama s brzim kretanjem. 0 automatski postavlja ovu vrijednost.", "transcoding_optimal_description": "Videozapisi koji su veći od ciljne rezolucije ili nisu u prihvatljivom formatu", @@ -468,6 +468,7 @@ "comments_are_disabled": "Komentari onemogućeni", "confirm": "Potvrdi", "confirm_admin_password": "Potvrdite lozinku administratora", + "confirm_delete_face": "Jeste li sigurni da želite izbrisati lice {name} iz knjižnice materijala.", "confirm_delete_shared_link": "Jeste li sigurni da želite izbrisati ovu zajedničku vezu?", "confirm_keep_this_delete_others": "Sva druga sredstva u nizu bit će izbrisana osim ovog sredstva. Jeste li sigurni da želite nastaviti?", "confirm_password": "Potvrdite lozinku", @@ -1252,4 +1253,4 @@ "yes": "", "you_dont_have_any_shared_links": "", "zoom_image": "" -} +} \ No newline at end of file diff --git a/i18n/hu.json b/i18n/hu.json index 7c1616c9a0..513775e45e 100644 --- a/i18n/hu.json +++ b/i18n/hu.json @@ -20,7 +20,7 @@ "add_partner": "Partner hozzáadása", "add_path": "Elérési útvonal megadása", "add_photos": "Fotók hozzáadása", - "add_to": "Hozzáadás ide...", + "add_to": "Hozzáadás ide…", "add_to_album": "Felvétel albumba", "add_to_shared_album": "Felvétel megosztott albumba", "add_url": "URL hozzáadása", @@ -41,6 +41,7 @@ "backup_settings": "Biztonsági mentés beállításai", "backup_settings_description": "Adatbázis mentési beállításainak kezelése", "check_all": "Összes Kipiálása", + "cleanup": "Takarítás", "cleared_jobs": "{job}: feladatai törölve", "config_set_by_file": "A konfigurációt jelenleg egy konfigurációs fájl állítja be", "confirm_delete_library": "Biztosan ki szeretnéd törölni a {library} képtárat?", @@ -96,7 +97,7 @@ "library_scanning_enable_description": "Képtár időszakos átfésülésének engedélyezése", "library_settings": "Külső Képtár", "library_settings_description": "Külső képtár beállításainak kezelése", - "library_tasks_description": "Képtár feladatok elvégzése", + "library_tasks_description": "Külső könyvtárak szkennelése új és/vagy módosított elemek után", "library_watching_enable_description": "Külső képtár változásainak figyelése", "library_watching_settings": "Képtár figyelése (KÍSÉRLETI)", "library_watching_settings_description": "Megváltozott fájlok automatikus észlelése", @@ -131,7 +132,7 @@ "machine_learning_smart_search_description": "Képek szemantikai keresése CLIP beágyazások segítségével", "machine_learning_smart_search_enabled": "Okos keresés engedélyezése", "machine_learning_smart_search_enabled_description": "Ha ki van kapcsolva, a képek nem lesznek átalakítva okos kereséshez.", - "machine_learning_url_description": "Gépi tanulás szerver URL címe. Ha többi, mint egy URL van megadva, mindegyik szervert egyenként próbálja meg, amíg az egyik sikeresen nem válaszol, sorrendben az elsőtől az utólsóig.", + "machine_learning_url_description": "Gépi tanulás szerver URL címe. Ha többi, mint egy URL van megadva, mindegyik szervert egyenként próbálja meg, amíg az egyik sikeresen nem válaszol, sorrendben az elsőtől az utólsóig. A nem válaszoló szervereket átmenetileg figyelmen kívül hagyja, amíg újra online nem lesznek.", "manage_concurrency": "Párhuzamos Feladatok Kezelése", "manage_log_settings": "Naplózási beállítások kezelése", "map_dark_style": "Sötét stílus", @@ -147,6 +148,8 @@ "map_settings": "Térkép", "map_settings_description": "Térkép beállítások kezelése", "map_style_description": "Egy style.json térképtémára mutató URL cím", + "memory_cleanup_job": "Memória takarítás", + "memory_generate_job": "Emlék létrehozása", "metadata_extraction_job": "Metaadatok kinyerése", "metadata_extraction_job_description": "Metaadat információk (pl. GPS, arcok és felbontás) kinyerése minden elemből", "metadata_faces_import_setting": "Arc importálás engedélyezése", @@ -219,7 +222,7 @@ "reset_settings_to_default": "Beállítások visszaállítása az alapértelmezettre", "reset_settings_to_recent_saved": "Beállítások visszaállítása a legutóbb mentettre", "scanning_library": "Képtár átfésülése", - "search_jobs": "Feladatok keresése...", + "search_jobs": "Feladatok keresése…", "send_welcome_email": "Üdvözlő email küldése", "server_external_domain_settings": "Külső domain", "server_external_domain_settings_description": "Nyilvánosan megosztott linkek domainje (http(s)://-sel)", @@ -299,7 +302,7 @@ "transcoding_max_b_frames": "B-képkockák maximum száma", "transcoding_max_b_frames_description": "Nagyobb értékek megnövelik a tömörítés hatékonyságát, de lelassítják a kódolást. Nem minden hardvereszköz támogatja. A 0 érték kikapcsolja a B-képkockákat, míg -1 esetén a szoftver magának választ értéket.", "transcoding_max_bitrate": "Maximum bitráta", - "transcoding_max_bitrate_description": "Maximum bitráta beállítása konzisztensebb fájlméretet eredményez egy kevés minőségi romlás árán. 720p esetén jellemző érték lehet 2600k a VP9 vagy HEVC kódoláshoz, 4500k a H.264 kódoláshoz. A 0 érték esetén nincs maximum bitráta.", + "transcoding_max_bitrate_description": "Maximum bitráta beállítása konzisztensebb fájlméretet eredményez egy kevés minőségi romlás árán. 720p esetén jellemző érték lehet 2600 kbit/s a VP9 vagy HEVC kódoláshoz, 4500 kbit/s a H.264 kódoláshoz. A 0 érték esetén nincs maximum bitráta.", "transcoding_max_keyframe_interval": "Maximum kulcskocka intervallum", "transcoding_max_keyframe_interval_description": "Beállítja a kulcskockák közötti legnagyobb lehetséges távolságot. Alacsony érték csökkenti a tömörítési hatékonyságot, de lejátszás közben az előre- és hátratekerés gyorsabb, valamint javíthatja a gyorsan mozgó jelenetek képminőségét. 0 esetén a szoftver magának állítja be az értéket.", "transcoding_optimal_description": "A célfelbontásnál nagyobb vagy a nem elfogadott formátumú videókat", @@ -391,6 +394,7 @@ "allow_edits": "Módosítások engedélyezése", "allow_public_user_to_download": "Engedélyezi a letöltést publikus felhasználó számára", "allow_public_user_to_upload": "Engedélyezi a feltöltést publikus felhasználó számára", + "alt_text_qr_code": "QR kód kép", "anti_clockwise": "Óramutató járásával ellentétes irány", "api_key": "API Kulcs", "api_key_description": "Ez csak most az egyszer jelenik meg. Az ablak bezárása előtt feltétlenül másold.", @@ -406,17 +410,17 @@ "are_these_the_same_person": "Ugyanaz a személy?", "are_you_sure_to_do_this": "Biztosan ezt szeretnéd csinálni?", "asset_added_to_album": "Hozzáadva az albumhoz", - "asset_adding_to_album": "Hozzáadás az albumhoz...", + "asset_adding_to_album": "Hozzáadás az albumhoz…", "asset_description_updated": "Az elem leírása frissült", "asset_filename_is_offline": "A(z) {filename} elem nem elérhető, mert offline", "asset_has_unassigned_faces": "Az elemnek hozzá nem rendelt arcai vannak", - "asset_hashing": "Hash számítása...", + "asset_hashing": "Hash számítása…", "asset_offline": "Elem Offline", "asset_offline_description": "Ez a külső elem már nem elérhető a lemezen. Kérlek, lépj kapcsolatba az Immich adminisztrátorával.", "asset_skipped": "Kihagyva", "asset_skipped_in_trash": "Lomtárban", "asset_uploaded": "Feltöltve", - "asset_uploading": "Feltöltés...", + "asset_uploading": "Feltöltés…", "assets": "Elemek", "assets_added_count": "{count, plural, other {# elem}} hozzáadva", "assets_added_to_album_count": "{count, plural, other {# elem}} hozzáadva az albumhoz", @@ -481,6 +485,7 @@ "comments_are_disabled": "A megjegyzések le vannak tiltva", "confirm": "Jóváhagy", "confirm_admin_password": "Admin Jelszó Újból", + "confirm_delete_face": "Biztos, hogy törölni szeretnéd a(z) {name} arcát az elemről?", "confirm_delete_shared_link": "Biztosan törölni szeretnéd ezt a megosztott linket?", "confirm_keep_this_delete_others": "Minden más elem a készletben törlésre kerül, kivéve ezt az elemet. Biztosan folytatni szeretnéd?", "confirm_password": "Jelszó megerősítése", @@ -533,6 +538,7 @@ "delete_album": "Album törlése", "delete_api_key_prompt": "Biztosan törölni szeretnéd ezt az API kulcsot?", "delete_duplicates_confirmation": "Biztosan véglegesen törölni szeretnéd ezeket a duplikátumokat?", + "delete_face": "Arc törlése", "delete_key": "Kulcs törlése", "delete_library": "Képtár Törlése", "delete_link": "Link törlése", @@ -600,6 +606,7 @@ "enabled": "Engedélyezve", "end_date": "Vég dátum", "error": "Hiba", + "error_delete_face": "Hiba az arc törlése során", "error_loading_image": "Hiba a kép betöltése közben", "error_title": "Hiba - valami félresikerült", "errors": { @@ -766,8 +773,10 @@ "go_to_folder": "Ugrás a mappához", "go_to_search": "Ugrás a kereséshez", "group_albums_by": "Albumok csoportosítása...", + "group_country": "Csoportosítás ország szerint", "group_no": "Nincs csoportosítás", "group_owner": "Csoportosítás tulajdonos szerint", + "group_places_by": "Helyszínek csoportosítása...", "group_year": "Csoportosítás év szerint", "has_quota": "Kvóta", "hi_user": "Szia {name} ({email})", @@ -800,6 +809,7 @@ "include_shared_albums": "Megosztott albumokkal együtt", "include_shared_partner_assets": "Partner által megosztott elemekkel együtt", "individual_share": "Egymagában megosztott elem", + "individual_shares": "Egyéni megosztások", "info": "Infó", "interval": { "day_at_onepm": "Minden nap 13 órakor", @@ -822,6 +832,7 @@ "latest_version": "Legfrissebb Verzió", "latitude": "Szélesség", "leave": "Elhagyás", + "lens_model": "Objektív modell", "let_others_respond": "Mások is reagálhatnak", "level": "Szint", "library": "Képtár", @@ -880,6 +891,7 @@ "month": "Hónap", "more": "Továbbiak", "moved_to_trash": "Áthelyezve a lomtárba", + "mute_memories": "Emlékek elnémítása", "my_albums": "Saját albumaim", "name": "Név", "name_or_nickname": "Név vagy becenév", @@ -984,6 +996,7 @@ "pick_a_location": "Hely választása", "place": "Hely", "places": "Helyek", + "places_count": "{count, plural, one {{count, number} Helyszín} other {{count, number} Helyszín}}", "play": "Lejátszás", "play_memories": "Emlékek lejátszása", "play_motion_photo": "Mozgókép lejátszása", @@ -1071,6 +1084,8 @@ "removed_from_archive": "Archívumból eltávolítva", "removed_from_favorites": "Kedvencekből eltávolítva", "removed_from_favorites_count": "A kedvencekből {count, plural, other {# elem}} eltávolítva", + "removed_memory": "Eltávolított emlék", + "removed_photo_from_memory": "Fotó törölve az emlékből", "removed_tagged_assets": "Címke eltávolítva {count, plural, one {# elemről} other {# elemről}}", "rename": "Átnevezés", "repair": "Javítás", @@ -1079,6 +1094,7 @@ "repository": "Tároló", "require_password": "Jelszó megadása szükséges", "require_user_to_change_password_on_first_login": "A felhasználónak kötelező megváltoztatnia a jelszavát az első bejelentkezéskor", + "rescan": "Újraszkennelés", "reset": "Visszaállítás", "reset_password": "Jelszó visszaállítása", "reset_people_visibility": "Személyek láthatóságának visszaállítása", @@ -1107,18 +1123,22 @@ "search": "Keresés", "search_albums": "Albumok keresése", "search_by_context": "Keresés tartalom alapján", + "search_by_description": "Keresés leírás alapján", + "search_by_description_example": "Túrázós nap Szapában", "search_by_filename": "Keresés fájlnév vagy kiterjesztés alapján", "search_by_filename_example": "például IMG_1234.JPG vagy PNG", "search_camera_make": "Kameragyártó keresése...", "search_camera_model": "Kameramodell keresése...", "search_city": "Város keresése...", "search_country": "Ország keresése...", + "search_for": "Keresés", "search_for_existing_person": "Már meglévő személy keresése", "search_no_people": "Nincs személy", "search_no_people_named": "Nincs \"{name}\" nevű személy", "search_options": "Keresési lehetőségek", "search_people": "Személyek keresése", "search_places": "Helyek keresése", + "search_rating": "Keresés értékelés szerint...", "search_settings": "Keresési beállítások", "search_state": "Megye/Állam keresése...", "search_tags": "Címkék keresése...", @@ -1165,6 +1185,7 @@ "shared_from_partner": "{partner} fényképei", "shared_link_options": "Megosztott link beállításai", "shared_links": "Megosztott linkek", + "shared_links_description": "Fényképek és videók megosztása linkkel", "shared_photos_and_videos_count": "{assetCount, plural, other {# megosztott kép és videó.}}", "shared_with_partner": "Megosztva {partner} partnereddel", "sharing": "Megosztás", @@ -1187,6 +1208,7 @@ "show_person_options": "Személy beállítások mutatása", "show_progress_bar": "Folyamatjelző Mutatása", "show_search_options": "Keresési lehetőségek mutatása", + "show_shared_links": "Megosztott linkek megjelenítése", "show_slideshow_transition": "Vetítés áttűnési effekt mutatása", "show_supporter_badge": "Támogató jelvény", "show_supporter_badge_description": "Támogató jelvény mutatása", @@ -1240,6 +1262,7 @@ "tag_created": "Létrehozott címke: {tag}", "tag_feature_description": "Fényképek és videók böngészése a címkék témája szerint csoportosítva", "tag_not_found_question": "Nem találod a címkét? Hozz létre egy új címkét", + "tag_people": "Emberek címkézése", "tag_updated": "Frissített címke: {tag}", "tagged_assets": "{count, plural, one {# elem} other {# elem}} felcímkézve", "tags": "Címkék", @@ -1274,11 +1297,13 @@ "unfavorite": "Kedvenc közül kivesz", "unhide_person": "Nem rejtett személy", "unknown": "Ismeretlen", + "unknown_country": "Ismeretlen ország", "unknown_year": "Ismeretlen Év", "unlimited": "Korlátlan", "unlink_motion_video": "Mozgókép leválasztása", "unlink_oauth": "OAuth leválasztása", "unlinked_oauth_account": "Leválasztott OAuth fiók", + "unmute_memories": "Emlékek mutatása", "unnamed_album": "Névtelen Album", "unnamed_album_delete_confirmation": "Biztosan törölni szeretnéd ezt az albumot?", "unnamed_share": "Névtelen Megosztás", @@ -1332,6 +1357,7 @@ "view_all": "Összes Megtekintése", "view_all_users": "Minden Felhasználó Megtekintése", "view_in_timeline": "Megtekintés az idővonalon", + "view_link": "Link megtekintése", "view_links": "Linkek megtekintése", "view_name": "Megtekintés", "view_next_asset": "Következő elem megtekintése", @@ -1348,4 +1374,4 @@ "yes": "Igen", "you_dont_have_any_shared_links": "Nincsenek megosztott linkjeid", "zoom_image": "Kép Nagyítása" -} +} \ No newline at end of file diff --git a/i18n/id.json b/i18n/id.json index 41ef0b008c..92f59363aa 100644 --- a/i18n/id.json +++ b/i18n/id.json @@ -20,7 +20,7 @@ "add_partner": "Tambahkan partner", "add_path": "Tambahkan jalur", "add_photos": "Tambahkan foto", - "add_to": "Tambahkan ke...", + "add_to": "Tambahkan ke…", "add_to_album": "Tambahkan ke album", "add_to_shared_album": "Tambahkan ke album terbagi", "add_url": "Tambahkan URL", @@ -41,6 +41,7 @@ "backup_settings": "Pengaturan Pencadangan", "backup_settings_description": "Kelola pengaturan pencadangan basis data", "check_all": "Periksa Semua", + "cleanup": "Pembersihan", "cleared_jobs": "Tugas terselesaikan untuk: {job}", "config_set_by_file": "Konfigurasi saat ini ditetapkan oleh berkas konfigurasi", "confirm_delete_library": "Apakah Anda yakin ingin menghapus pustaka {library}?", @@ -96,7 +97,7 @@ "library_scanning_enable_description": "Aktifkan pemindaian pustaka berkala", "library_settings": "Pustaka Eksternal", "library_settings_description": "Kelola pengaturan pustaka eksternal", - "library_tasks_description": "Lakukan tugas pustaka", + "library_tasks_description": "Pindai pustaka eksternal untuk aset baru dan/atau berubah", "library_watching_enable_description": "Pantau perubahan berkas dalam pustaka eksternal", "library_watching_settings": "Pemantauan pustaka (UJI COBA)", "library_watching_settings_description": "Pantau berkas yang telah diubah secara otomatis", @@ -131,7 +132,7 @@ "machine_learning_smart_search_description": "Cari gambar secara semantik menggunakan penyematan CLIP", "machine_learning_smart_search_enabled": "Aktifkan pencarian pintar", "machine_learning_smart_search_enabled_description": "Jika dinonaktifkan, gambar tidak akan dienkode untuk pencarian pintar.", - "machine_learning_url_description": "URL server pembelajaran mesin. Jika lebih dari satu URL disediakan, setiap server akan dicoba satu per satu sampai salah satu berhasil merespons, dari urutan pertama sampai terakhir.", + "machine_learning_url_description": "URL server pembelajaran mesin. Jika lebih dari satu URL disediakan, setiap server akan dicoba satu per satu sampai salah satu berhasil merespons, dari urutan pertama sampai terakhir. Server yang tidak merespons akan diabaikan sementara sampai kembali daring.", "manage_concurrency": "Kelola Konkurensi", "manage_log_settings": "Kelola pengaturan log", "map_dark_style": "Gaya gelap", @@ -147,6 +148,8 @@ "map_settings": "Peta", "map_settings_description": "Kelola pengaturan peta", "map_style_description": "URL ke tema peta style.json", + "memory_cleanup_job": "Pembersihan memori", + "memory_generate_job": "Pembuatan memori", "metadata_extraction_job": "Ekstrak metadata", "metadata_extraction_job_description": "Ekstrak informasi metadata dari setiap aset, seperti GPS, wajah dan resolusi", "metadata_faces_import_setting": "Aktifkan impor wajah", @@ -219,7 +222,7 @@ "reset_settings_to_default": "Atur ulang pengaturan ke bawaan", "reset_settings_to_recent_saved": "Atur ulang pengaturan ke pengaturan tersimpan terkini", "scanning_library": "Memindai pustaka", - "search_jobs": "Mencari tugas...", + "search_jobs": "Mencari tugas…", "send_welcome_email": "Kirim surel selamat datang", "server_external_domain_settings": "Domain eksternal", "server_external_domain_settings_description": "Domain untuk tautan terbagi publik, termasuk http(s)://", @@ -240,7 +243,7 @@ "storage_template_hash_verification_enabled_description": "Mengaktifkan verifikasi hash, jangan mengaktifkan ini kecuali Anda sudah tahu kekurangannya", "storage_template_migration": "Migrasi templat penyimpanan", "storage_template_migration_description": "Tetapkan {template} saat ini pada aset yang sebelumnya diunggah", - "storage_template_migration_info": "Perubahan templat hanya akan diterapkan pada aset baru. Untuk menerapkan templat pada setiap aset yang sebelumnya telah diunggah, jalankan {job}.", + "storage_template_migration_info": "Templat penyimpanan akan mengubah semua ekstensi ke huruf kecil. Perubahan templat hanya akan diterapkan pada aset baru. Untuk menerapkan templat pada setiap aset yang sebelumnya telah diunggah, jalankan {job}.", "storage_template_migration_job": "Tugas Migrasi Templat Ruang Penyimpanan", "storage_template_more_details": "Untuk detail lebih lanjut tentang fitur ini, pergi ke Templat Penyimpanan dan kekurangannya", "storage_template_onboarding_description": "Ketika diaktifkan, fitur ini akan mengelola berkas secara otomatis berdasarkan templat pengguna. Karena masalah stabilitas, fitur ini telah dimatikan secara bawaan. Untuk informasi lebih lanjut, silakan lihat dokumentasi.", @@ -299,7 +302,7 @@ "transcoding_max_b_frames": "Bingkai B maksimum", "transcoding_max_b_frames_description": "Nilai yang lebih tinggi meningkatkan efisiensi kompresi, tetapi membuat pengodean lebih lambat. Mungkin tidak kompatibel dengan akselerasi perangkat keras pada perangkat lawas. 0 menonaktifkan bingkai B, sedangkan -1 mengatur nilai ini secara otomatis.", "transcoding_max_bitrate": "Kecepatan bit maksimum", - "transcoding_max_bitrate_description": "Menetapkan kecepatan bit maksimum dapat membuat ukuran berkas lebih dapat diprediksi dengan kekurangan minor pada kualitas. Pada 720p, nilai umum adalah 2600k untuk VP9 atau HEVC, atau 4500k untuk H.264. Dinonaktifkan jika ditetapkan ke 0.", + "transcoding_max_bitrate_description": "Menetapkan kecepatan bit maksimum dapat membuat ukuran berkas lebih dapat diprediksi dengan kekurangan minor pada kualitas. Pada 720p, nilai umum adalah 2600 kbit/s untuk VP9 atau HEVC, atau 4500 kbit/s untuk H.264. Dinonaktifkan jika ditetapkan ke 0.", "transcoding_max_keyframe_interval": "Interval bingkai kunci maksimum", "transcoding_max_keyframe_interval_description": "Menetapkan jarak bingkai maksimum antara bingkai kunci. Nilai yang lebih rendah membuat efisiensi kompresi lebih buruk, tetapi meningkatkan waktu pencarian dan dapat meningkatkan kualitas dalam adegan dengan gerakan cepat. 0 menetapkan nilai ini secara otomatis.", "transcoding_optimal_description": "Video lebih tinggi dari resolusi sasaran atau tidak dalam format yang diterima", @@ -391,6 +394,7 @@ "allow_edits": "Perbolehkan penyuntingan", "allow_public_user_to_download": "Perbolehkan pengguna publik untuk mengunduh", "allow_public_user_to_upload": "Perbolehkan pengguna publik untuk mengunggah", + "alt_text_qr_code": "Gambar kode QR", "anti_clockwise": "Berlawanan arah jarum jam", "api_key": "Kunci API", "api_key_description": "Nilai ini hanya akan ditampilkan sekali. Pastikan untuk menyalin sebelum menutup jendela ini.", @@ -406,17 +410,17 @@ "are_these_the_same_person": "Apakah ini adalah orang yang sama?", "are_you_sure_to_do_this": "Apakah Anda yakin ingin melakukan ini?", "asset_added_to_album": "Telah ditambahkan ke album", - "asset_adding_to_album": "Menambahkan ke album...", + "asset_adding_to_album": "Menambahkan ke album…", "asset_description_updated": "Deskripsi aset telah diperbarui", "asset_filename_is_offline": "Aset {filename} sedang luring", "asset_has_unassigned_faces": "Aset memiliki wajah yang belum ditetapkan", - "asset_hashing": "Memilah...", + "asset_hashing": "Memilah…", "asset_offline": "Aset Luring", "asset_offline_description": "Aset eksternal ini tidak ada lagi di diska. Silakan hubungi administrator Immich Anda untuk bantuan.", "asset_skipped": "Dilewati", "asset_skipped_in_trash": "Dalam sampah", "asset_uploaded": "Sudah diunggah", - "asset_uploading": "Mengunggah...", + "asset_uploading": "Mengunggah…", "assets": "Aset", "assets_added_count": "{count, plural, one {# aset} other {# aset}} ditambahkan", "assets_added_to_album_count": "Ditambahkan {count, plural, one {# aset} other {# aset}} ke album", @@ -481,6 +485,7 @@ "comments_are_disabled": "Komentar dinonaktifkan", "confirm": "Konfirmasi", "confirm_admin_password": "Konfirmasi Kata Sandi Admin", + "confirm_delete_face": "Apakah Anda yakin ingin menghapus wajah {name} dari aset?", "confirm_delete_shared_link": "Apakah Anda yakin ingin menghapus tautan terbagi ini?", "confirm_keep_this_delete_others": "Semua aset lain di dalam stack akan dihapus kecuali aset ini. Anda yakin untuk melanjutkan?", "confirm_password": "Konfirmasi kata sandi", @@ -533,6 +538,7 @@ "delete_album": "Hapus album", "delete_api_key_prompt": "Apakah Anda yakin ingin menghapus kunci API ini?", "delete_duplicates_confirmation": "Apakah Anda yakin ingin menghapus duplikat ini secara permanen?", + "delete_face": "Hapus wajah", "delete_key": "Hapus kunci", "delete_library": "Hapus Pustaka", "delete_link": "Hapus tautan", @@ -600,6 +606,7 @@ "enabled": "Diaktifkan", "end_date": "Tanggal akhir", "error": "Eror", + "error_delete_face": "Terjadi kesalahan menghapus wajah dari aset", "error_loading_image": "Terjadi eror memuat gambar", "error_title": "Eror - Ada yang salah", "errors": { @@ -766,8 +773,10 @@ "go_to_folder": "Pergi ke folder", "go_to_search": "Pergi ke pencarian", "group_albums_by": "Kelompokkan album berdasarkan...", + "group_country": "Kelompokkan berdasarkan negara", "group_no": "Tidak ada pengelompokan", "group_owner": "Kelompokkan berdasarkan pemilik", + "group_places_by": "Kelompokkan tempat berdasarkan…", "group_year": "Kelompokkan berdasarkan tahun", "has_quota": "Memiliki kuota", "hi_user": "Hai {name} ({email})", @@ -800,6 +809,7 @@ "include_shared_albums": "Termasuk album terbagi", "include_shared_partner_assets": "Termasuk aset terbagi dengan partner", "individual_share": "Bagikan individu", + "individual_shares": "Pembagian individu", "info": "Info", "interval": { "day_at_onepm": "Setiap hari pada 13.00", @@ -822,6 +832,7 @@ "latest_version": "Versi Terkini", "latitude": "Lintang", "leave": "Tinggalkan", + "lens_model": "Model lensa", "let_others_respond": "Biarkan orang lain merespons", "level": "Tingkat", "library": "Pustaka", @@ -880,6 +891,7 @@ "month": "Bulan", "more": "Lainnya", "moved_to_trash": "Dipindahkan ke sampah", + "mute_memories": "Nonaktifkan Kenangan", "my_albums": "Album saya", "name": "Nama", "name_or_nickname": "Nama atau nama panggilan", @@ -984,6 +996,7 @@ "pick_a_location": "Pilih lokasi", "place": "Tempat", "places": "Tempat", + "places_count": "{count, plural, one {{count, number} Tempat} other {{count, number} Tempat}}", "play": "Putar", "play_memories": "Putar kenangan", "play_motion_photo": "Putar Foto Gerak", @@ -1071,6 +1084,8 @@ "removed_from_archive": "Dihapus dari arsip", "removed_from_favorites": "Dihapus dari favorit", "removed_from_favorites_count": "{count, plural, other {Menghapus #}} dari favorit", + "removed_memory": "Memori dihapus", + "removed_photo_from_memory": "Foto dihapus dari memori", "removed_tagged_assets": "Hapus tag dari {count, plural, one {# aset} other {# aset}}", "rename": "Ubah nama", "repair": "Perbaiki", @@ -1079,6 +1094,7 @@ "repository": "Repositori", "require_password": "Memerlukan kata sandi", "require_user_to_change_password_on_first_login": "Memerlukan pengguna untuk mengubah kata sandi pada log masuk pertama", + "rescan": "Pindai ulang", "reset": "Atur ulang", "reset_password": "Atur ulang kata sandi", "reset_people_visibility": "Atur ulang keterlihatan orang", @@ -1107,18 +1123,22 @@ "search": "Cari", "search_albums": "Cari album", "search_by_context": "Cari berdasarkan konteks", + "search_by_description": "Cari berdasarkan deskripsi", + "search_by_description_example": "Hari mendaki di Sapa", "search_by_filename": "Cari berdasarkan nama berkas atau ekstensi", "search_by_filename_example": "mis. IMG_1234.JPG atau PNG", "search_camera_make": "Cari merek kamera...", "search_camera_model": "Cari model kamera...", "search_city": "Cari kota...", "search_country": "Cari negara...", + "search_for": "Cari", "search_for_existing_person": "Cari orang yang sudah ada", "search_no_people": "Tidak ada orang", "search_no_people_named": "Tidak ada orang bernama \"{name}\"", "search_options": "Pilihan pencarian", "search_people": "Cari orang", "search_places": "Cari tempat", + "search_rating": "Cari berdasarkan penilaian...", "search_settings": "Pengaturan pencarian", "search_state": "Cari negara bagian...", "search_tags": "Cari tag...", @@ -1165,6 +1185,7 @@ "shared_from_partner": "Foto dari {partner}", "shared_link_options": "Pilihan tautan bersama", "shared_links": "Tautan terbagi", + "shared_links_description": "Bagikan foto dan video dengan tautan", "shared_photos_and_videos_count": "{assetCount, plural, other {# foto & video terbagi.}}", "shared_with_partner": "Dibagikan dengan {partner}", "sharing": "Pembagian", @@ -1187,6 +1208,7 @@ "show_person_options": "Tampilkan opsi orang", "show_progress_bar": "Tampilkan Bilah Progres", "show_search_options": "Tampilkan opsi pencarian", + "show_shared_links": "Tampilkan tautan terbagi", "show_slideshow_transition": "Tampilkan transisi salindia", "show_supporter_badge": "Lencana suporter", "show_supporter_badge_description": "Tampilkan lencana suporter", @@ -1240,6 +1262,7 @@ "tag_created": "Tag yang di buat: {tag}", "tag_feature_description": "Menjelajahi foto dan video yang dikelompokkan berdasarkan topik tag logis", "tag_not_found_question": "Tidak dapat menemukan tag? Buat tag baru.", + "tag_people": "Tandai Orang", "tag_updated": "Tag yang diperbarui: {tag}", "tagged_assets": "Ditandai {count, plural, one {# aset} other {# aset}}", "tags": "Tag", @@ -1274,11 +1297,13 @@ "unfavorite": "Hapus favorit", "unhide_person": "Munculkan orang", "unknown": "Tidak diketahui", + "unknown_country": "Negara Tidak Diketahui", "unknown_year": "Tahun Tidak Diketahui", "unlimited": "Tidak terbatas", "unlink_motion_video": "Membatalkan tautan video gerak", "unlink_oauth": "Putuskan OAuth", "unlinked_oauth_account": "Akun OAuth terputus", + "unmute_memories": "Aktifkan Kenangan", "unnamed_album": "Album Tanpa Nama", "unnamed_album_delete_confirmation": "Apakah kamu yakin akan menghapus album ini?", "unnamed_share": "Pembagian Tanpa Nama", @@ -1332,6 +1357,7 @@ "view_all": "Tampilkan Semua", "view_all_users": "Tampilkan semua pengguna", "view_in_timeline": "Lihat di timeline", + "view_link": "Tampilkan tautan", "view_links": "Tampilkan tautan", "view_name": "Tampilkan", "view_next_asset": "Tampilkan aset berikutnya", @@ -1348,4 +1374,4 @@ "yes": "Ya", "you_dont_have_any_shared_links": "Anda tidak memiliki tautan terbagi", "zoom_image": "Perbesar Gambar" -} +} \ No newline at end of file diff --git a/i18n/it.json b/i18n/it.json index bd05f8e555..358a96d28a 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -1,46 +1,47 @@ { - "about": "Informazioni su", + "about": "Informazioni", "account": "Profilo", - "account_settings": "Impostazioni Account", - "acknowledge": "Acconsento", + "account_settings": "Impostazioni Profilo", + "acknowledge": "Ho capito", "action": "Azione", "actions": "Azioni", - "active": "Attivi", + "active": "Attivo", "activity": "Attività", "activity_changed": "L'attività è {enabled, select, true {abilitata} other {disabilitata}}", "add": "Aggiungi", "add_a_description": "Aggiungi una descrizione", - "add_a_location": "Aggiungi un luogo", + "add_a_location": "Aggiungi una posizione", "add_a_name": "Aggiungi un nome", "add_a_title": "Aggiungi un titolo", "add_exclusion_pattern": "Aggiungi un pattern di esclusione", "add_import_path": "Aggiungi un percorso di importazione", "add_location": "Aggiungi posizione", "add_more_users": "Aggiungi altri utenti", - "add_partner": "Aggiungi un partner", + "add_partner": "Aggiungi partner", "add_path": "Aggiungi percorso", "add_photos": "Aggiungi foto", - "add_to": "Aggiungi a...", + "add_to": "Aggiungi a…", "add_to_album": "Aggiungi all'album", - "add_to_shared_album": "Aggiungi all'album condiviso", + "add_to_shared_album": "Aggiungi ad album condiviso", "add_url": "Aggiungi URL", "added_to_archive": "Aggiunto all'archivio", "added_to_favorites": "Aggiunto ai preferiti", - "added_to_favorites_count": "Aggiunti {count, number} ai preferiti", + "added_to_favorites_count": "Aggiunto {count, number} ai preferiti", "admin": { "add_exclusion_pattern_description": "Aggiungi modelli di esclusione. È supportato il globbing utilizzando *, ** e ?. Per ignorare tutti i file in qualsiasi directory denominata \"Raw\", usa \"**/Raw/**\". Per ignorare tutti i file con estensione \".tif\", usa \"**/*.tif\". Per ignorare un percorso assoluto, usa \"/percorso/da/ignorare/**\".", - "asset_offline_description": "Questa risorsa della libreria esterna non si trova più sul disco ed è stata spostata nel cestino. Se il file è stato spostato all'interno della libreria, controlla la timeline per la nuova risorsa corrispondente. Per ripristinare questa risorsa, assicurati che Immich possa accedere al percorso del file seguente ed esegui la scansione della libreria.", - "authentication_settings": "Autenticazione", + "asset_offline_description": "Questa risorsa della libreria esterna non si trova più sul disco ed è stata spostata nel cestino. Se il file è stato spostato all'interno della libreria, controlla la timeline per la nuova risorsa corrispondente. Per ripristinare questa risorsa, assicurati che Immich possa accedere al percorso del file ed esegui la scansione della libreria.", + "authentication_settings": "Impostazioni di Autenticazione", "authentication_settings_description": "Gestisci password, OAuth e altre impostazioni di autenticazione", "authentication_settings_disable_all": "Sei sicuro di voler disabilitare tutte le modalità di accesso? Il login verrà disabilitato completamente.", - "authentication_settings_reenable": "Per riabilitare, utilizza un Comando Server.", + "authentication_settings_reenable": "Per ri-abilitare, utilizza un Comando Server.", "background_task_job": "Attività in Background", - "backup_database": "Backup Database", + "backup_database": "Database di Backup", "backup_database_enable_description": "Abilita i backup del database", "backup_keep_last_amount": "Quantità di backup precedenti da mantenere", - "backup_settings": "Impostazioni backup", + "backup_settings": "Impostazioni di backup", "backup_settings_description": "Gestisci le impostazioni dei backup", "check_all": "Controlla Tutto", + "cleanup": "Pulisci", "cleared_jobs": "Cancellati i processi per: {job}", "config_set_by_file": "La configurazione è attualmente impostata da un file di configurazione", "confirm_delete_library": "Sei sicuro di voler cancellare la libreria {library}?", @@ -48,7 +49,7 @@ "confirm_email_below": "Per confermare, scrivi \"{email}\" qui sotto", "confirm_reprocess_all_faces": "Sei sicuro di voler riprocessare tutti i volti? Questo cancellerà tutte le persone nominate.", "confirm_user_password_reset": "Sei sicuro di voler resettare la password di {user}?", - "create_job": "creare lavoro", + "create_job": "Crea un lavoro", "cron_expression": "Espressione Cron", "cron_expression_description": "Imposta il tempo di scansione utilizzando il formato Cron. Per ulteriori informazioni fare riferimento a Crontab Guru", "cron_expression_presets": "Espressione Cron preimpostata", @@ -63,7 +64,7 @@ "failed_job_command": "Il comando {command} è fallito per il processo: {job}", "force_delete_user_warning": "ATTENZIONE: Questo rimuoverà immediatamente l'utente e tutti i suoi assets. Non è possibile tornare indietro e i file non potranno essere recuperati.", "forcing_refresh_library_files": "Forzando l'aggiornamento completo della libreria", - "image_format": "formato", + "image_format": "Formato", "image_format_description": "WebP produce file più piccoli rispetto a JPEG, ma l'encoding è più lento.", "image_prefer_embedded_preview": "Preferisci l'anteprima integrata", "image_prefer_embedded_preview_setting_description": "Usa l'anteprima integrata nelle foto RAW come input per l'elaborazione delle immagini, se disponibile. Questo permette un miglioramento dei colori per alcune immagini, ma la qualità delle anteprime dipende dalla macchina fotografica. Inoltre le immagini potrebbero presentare artefatti di compressione.", @@ -96,7 +97,7 @@ "library_scanning_enable_description": "Attiva la scansione periodica della libreria", "library_settings": "Libreria Esterna", "library_settings_description": "Gestisci le impostazioni della libreria esterna", - "library_tasks_description": "Esegui processi della libreria", + "library_tasks_description": "Scansiona le librerie esterne per i nuovi aggiornamenti", "library_watching_enable_description": "Osserva le librerie esterne per cambiamenti", "library_watching_settings": "Osserva librerie (SPERIMENTALE)", "library_watching_settings_description": "Osserva automaticamente i cambiamenti dei file", @@ -131,7 +132,7 @@ "machine_learning_smart_search_description": "Cerca immagini semanticamente utilizzato gli embedding CLIP", "machine_learning_smart_search_enabled": "Attiva ricerca intelligente", "machine_learning_smart_search_enabled_description": "Se disabilitato le immagini non saranno codificate per la ricerca intelligente.", - "machine_learning_url_description": "URL del server machine learning. Se sono stati forniti più di un URL, verrà testato un server alla volta finché uno non risponderà, in ordine dal primo all'ultimo.", + "machine_learning_url_description": "URL del server machine learning. Se sono stati forniti più di un URL, verrà testato un server alla volta finché uno non risponderà, in ordine dal primo all'ultimo. I server che non rispondono saranno temporaneamente ignorati finché non torneranno online.", "manage_concurrency": "Gestisci Concorrenza", "manage_log_settings": "Gestisci le impostazioni dei log", "map_dark_style": "Tema scuro", @@ -147,6 +148,8 @@ "map_settings": "Impostazioni Mappa e Posizione", "map_settings_description": "Gestisci impostazioni mappa", "map_style_description": "URL per un tema della mappa style.json", + "memory_cleanup_job": "pulizia memoria", + "memory_generate_job": "Generazione della memoria", "metadata_extraction_job": "Estrazione Metadata", "metadata_extraction_job_description": "Estrai informazioni dai metadati di ciascun asset, ad esempio coordinate GPS, volti e risoluzione", "metadata_faces_import_setting": "Abilita l'importazione dei volti", @@ -219,7 +222,7 @@ "reset_settings_to_default": "Ripristina impostazioni predefinite", "reset_settings_to_recent_saved": "Ripristina impostazioni alle impostazioni salvate di recente", "scanning_library": "Scansione della libreria", - "search_jobs": "Cerca Jobs...", + "search_jobs": "Cerca Attività…", "send_welcome_email": "Invia email di benvenuto", "server_external_domain_settings": "Dominio esterno", "server_external_domain_settings_description": "Dominio per link condivisi pubblicamente, incluso http(s)://", @@ -299,7 +302,7 @@ "transcoding_max_b_frames": "B-frames Massimi", "transcoding_max_b_frames_description": "Valori più alti migliorano l'efficienza di compressione, ma rallentano l'encoding. Potrebbero non essere compatibili con l'accelerazione hardware su dispositivi più vecchi. 0 disabilita i B-frames, mentre -1 imposta questo valore automaticamente.", "transcoding_max_bitrate": "Bitrate massimo", - "transcoding_max_bitrate_description": "Impostare un bitrate massimo può rendere le dimensioni dei file più prevedibili a un costo minore per la qualità. A 720p, i valori tipici sono 2600k per VP9 o HEVC, o 4500k per H.264. Disabilitato se impostato su 0.", + "transcoding_max_bitrate_description": "Impostare un bitrate massimo può rendere le dimensioni dei file più prevedibili a un costo minore per la qualità. A 720p, i valori tipici sono 2600 kbit/s per VP9 o HEVC, o 4500 kbit/s per H.264. Disabilitato se impostato su 0.", "transcoding_max_keyframe_interval": "Intervallo massimo dei keyframe", "transcoding_max_keyframe_interval_description": "Imposta la distanza massima tra i keyframe. Valori più bassi peggiorano l'efficienza di compressione, però migliorano i tempi di ricerca e possono migliorare la qualità nelle scene con movimenti rapidi. 0 imposta questo valore automaticamente.", "transcoding_optimal_description": "Video con risoluzione più alta rispetto alla risoluzione desiderata o in formato non accettato", @@ -374,11 +377,11 @@ "album_name": "Nome Album", "album_options": "Impostazioni Album", "album_remove_user": "Rimuovi l'utente?", - "album_remove_user_confirmation": "Sicuro di voler cancellare l'utente {user}?", + "album_remove_user_confirmation": "Sicuro di voler rimuovere l'utente {user}?", "album_share_no_users": "Sembra che tu abbia condiviso questo album con tutti gli utenti oppure non hai nessun utente con cui condividere.", "album_updated": "Album aggiornato", - "album_updated_setting_description": "Ricevi una notifica email quando un album condiviso ha nuovi asset", - "album_user_left": "Abbandona {album}", + "album_updated_setting_description": "Ricevi una notifica email quando un album condiviso ha nuovi media", + "album_user_left": "{album} abbandonato", "album_user_removed": "Utente {user} rimosso", "album_with_link_access": "Permetti a chiunque possieda il link di visualizzare le foto e le persone dell'album.", "albums": "Album", @@ -391,10 +394,11 @@ "allow_edits": "Permetti Modifiche", "allow_public_user_to_download": "Permetti agli utenti pubblici di scaricare", "allow_public_user_to_upload": "Permetti agli utenti pubblici di caricare", - "anti_clockwise": "Senso Anti-Orario", + "alt_text_qr_code": "Immagine QR", + "anti_clockwise": "Senso anti-orario", "api_key": "Chiave API", - "api_key_description": "Il campo verrà mostrato solo una volta. Abbi cura di copiarlo prima di chiudere la finestra.", - "api_key_empty": "Il Nome dell'API Key non può essere vuoto", + "api_key_description": "Il valore verrà mostrato solo una volta. Assicurati di copiarlo prima di chiudere la finestra.", + "api_key_empty": "Il nome della chiave API non può essere vuoto", "api_keys": "Chiavi API", "app_settings": "Impostazioni Applicazione", "appears_in": "Compare in", @@ -406,17 +410,17 @@ "are_these_the_same_person": "Sono la stessa persona?", "are_you_sure_to_do_this": "Sei sicuro di voler procedere?", "asset_added_to_album": "Aggiunto all'album", - "asset_adding_to_album": "In aggiunta all'album...", - "asset_description_updated": "La descrizione del media non è stata aggiornata", + "asset_adding_to_album": "Aggiungendo all'album…", + "asset_description_updated": "La descrizione del media è stata aggiornata", "asset_filename_is_offline": "Il media {filename} è offline", "asset_has_unassigned_faces": "Il media ha dei volti non categorizzati", - "asset_hashing": "Hashing in corso ...", + "asset_hashing": "Hashing in corso …", "asset_offline": "Risorsa Offline", "asset_offline_description": "Questo media non è stato trovato nel disco. Contatta il tuo amministratore di Immich per assistenza.", "asset_skipped": "Saltato", - "asset_skipped_in_trash": "In cestino", + "asset_skipped_in_trash": "Nel cestino", "asset_uploaded": "Caricato", - "asset_uploading": "Caricamento...", + "asset_uploading": "Caricamento…", "assets": "Risorse", "assets_added_count": "{count, plural, one {# asset aggiunto} other {# asset aggiunti}}", "assets_added_to_album_count": "{count, plural, one {# asset aggiunto} other {# asset aggiunti}} all'album", @@ -434,7 +438,7 @@ "back_close_deselect": "Indietro, chiudi o deseleziona", "backward": "Indietro", "birthdate_saved": "Data di nascita salvata con successo", - "birthdate_set_description": "La data di nascita è usata per calcolare l'età di questa persona nel momento dello scatto della foto.", + "birthdate_set_description": "La data di nascita è usata per calcolare l'età di questa persona al momento dello scatto della foto.", "blurred_background": "Sfondo sfocato", "bugs_and_feature_requests": "Bug & Richieste di nuove funzionalità", "build": "Compilazione", @@ -442,7 +446,7 @@ "bulk_delete_duplicates_confirmation": "Sei sicuro di voler cancellare {count, plural, one {# asset duplicato} other {# assets duplicati}}? Questa operazione manterrà l'asset più pesante di ogni gruppo e cancellerà permanentemente tutti gli altri duplicati. Non puoi annullare questa operazione!", "bulk_keep_duplicates_confirmation": "Sei sicuro di voler tenere {count, plural, one {# asset duplicato} other {# assets duplicati}}? Questa operazione risolverà tutti i gruppi duplicati senza cancellare nulla.", "bulk_trash_duplicates_confirmation": "Sei davvero sicuro di voler cancellare {count, plural, one {# asset duplicato} other {# assets duplicati}}? Questa operazione manterrà l'asset più pesante di ogni gruppo e cancellerà permanentemente tutti gli altri duplicati.", - "buy": "Acquistare Immich", + "buy": "Acquista Immich", "camera": "Fotocamera", "camera_brand": "Marca fotocamera", "camera_model": "Modello fotocamera", @@ -480,11 +484,12 @@ "comments_and_likes": "Commenti & mi piace", "comments_are_disabled": "I commenti sono disabilitati", "confirm": "Conferma", - "confirm_admin_password": "Conferma password amministratore", + "confirm_admin_password": "Conferma password dell'amministratore", + "confirm_delete_face": "Sei sicuro di voler cancellare il volto di {name} dall'asset?", "confirm_delete_shared_link": "Sei sicuro di voler eliminare questo link condiviso?", - "confirm_keep_this_delete_others": "Tutti gli altri asset nello stack saranno eliminati, eccetto questo asset. Vuoi continuare?", + "confirm_keep_this_delete_others": "Tutti gli altri asset nello stack saranno eliminati, eccetto questo asset. Sei sicuro di voler continuare?", "confirm_password": "Conferma password", - "contain": "Adatta", + "contain": "Adatta alla finestra", "context": "Contesto", "continue": "Continua", "copied_image_to_clipboard": "Immagine copiata negli appunti.", @@ -497,8 +502,8 @@ "copy_password": "Copia password", "copy_to_clipboard": "Copia negli appunti", "country": "Nazione", - "cover": "Riempi", - "covers": "Miniature", + "cover": "Riempi la finestra", + "covers": "Copre", "create": "Crea", "create_album": "Crea album", "create_library": "Crea libreria", @@ -509,21 +514,21 @@ "create_new_person_hint": "Assegna gli asset selezionati a una nuova persona", "create_new_user": "Crea nuovo utente", "create_tag": "Crea tag", - "create_tag_description": "Crea un nuova tag. Per i tag annidati, si prega di inserire il percorso completo del tag tra cui slash in avanti.", + "create_tag_description": "Crea un nuovo tag. Per i tag annidati, si prega di inserire il percorso completo del tag tra cui barre oblique.", "create_user": "Crea utente", "created": "Creato", - "current_device": "Dispositivo corrente", + "current_device": "Dispositivo attuale", "custom_locale": "Localizzazione personalizzata", "custom_locale_description": "Formatta data e numeri in base alla lingua e al paese", "dark": "Scuro", "date_after": "Data dopo", - "date_and_time": "Data e tempo", + "date_and_time": "Data e ora", "date_before": "Data prima", "date_of_birth_saved": "Data di nascita salvata con successo", "date_range": "Intervallo di date", "day": "Giorno", - "deduplicate_all": "De-duplica Tutti", - "deduplication_criteria_1": "Dimensione immagine in byte", + "deduplicate_all": "Duplica Tutti", + "deduplication_criteria_1": "Dimensione immagine in bytes", "deduplication_criteria_2": "Numero di dati EXIF", "deduplication_info": "Informazioni di deduplicazione", "deduplication_info_description": "Per preselezionare automaticamente gli asset e rimuovere i duplicati in massa, verifichiamo:", @@ -533,6 +538,7 @@ "delete_album": "Elimina album", "delete_api_key_prompt": "Sei sicuro di voler eliminare questa chiave API?", "delete_duplicates_confirmation": "Sei sicuro di voler eliminare questi duplicati per sempre?", + "delete_face": "cancella faccia", "delete_key": "Elimina chiave", "delete_library": "Elimina libreria", "delete_link": "Elimina link", @@ -600,6 +606,7 @@ "enabled": "Abilitato", "end_date": "Data Fine", "error": "Errore", + "error_delete_face": "Errore nel cancellare la faccia dalla foto", "error_loading_image": "Errore nel caricamento dell'immagine", "error_title": "Errore - Qualcosa è andato storto", "errors": { @@ -766,8 +773,10 @@ "go_to_folder": "Vai alla cartella", "go_to_search": "Vai alla ricerca", "group_albums_by": "Raggruppa album in base a...", + "group_country": "Raggruppa per paese", "group_no": "Nessun raggruppamento", "group_owner": "Raggruppa in base al proprietario", + "group_places_by": "Raggruppa posti per...", "group_year": "Raggruppa per anno", "has_quota": "Ha limite", "hi_user": "Ciao {name} ({email})", @@ -800,6 +809,7 @@ "include_shared_albums": "Includi album condivisi", "include_shared_partner_assets": "Includi asset condivisi del compagno", "individual_share": "Condivisione individuale", + "individual_shares": "Condivisioni individuali", "info": "Info", "interval": { "day_at_onepm": "Ogni giorno alle 13", @@ -822,6 +832,7 @@ "latest_version": "Ultima Versione", "latitude": "Latitudine", "leave": "Esci", + "lens_model": "Modello lenti", "let_others_respond": "Permetti agli altri di rispondere", "level": "Livello", "library": "Libreria", @@ -880,6 +891,7 @@ "month": "Mese", "more": "Di più", "moved_to_trash": "Spostato nel cestino", + "mute_memories": "Silenzia ricordi", "my_albums": "I miei album", "name": "Nome", "name_or_nickname": "Nome o soprannome", @@ -984,6 +996,7 @@ "pick_a_location": "Scegli una posizione", "place": "Posizione", "places": "Luoghi", + "places_count": "{count, plural, one {{count, number} Luogo} other {{count, number} Places}}", "play": "Avvia", "play_memories": "Avvia ricordi", "play_motion_photo": "Avvia Foto in movimento", @@ -1071,6 +1084,8 @@ "removed_from_archive": "Rimosso dall'archivio", "removed_from_favorites": "Rimosso dai preferiti", "removed_from_favorites_count": "{count, plural, one {Rimosso } other {Rimossi #}} dai preferiti", + "removed_memory": "Memoria rimossa", + "removed_photo_from_memory": "Foto rimossa dalla memoria", "removed_tagged_assets": "Rimossa etichetta {count, plural, one {# dall'asset} other {# dagli asset}}", "rename": "Rinomina", "repair": "Ripara", @@ -1079,6 +1094,7 @@ "repository": "Repository", "require_password": "Richiedi password", "require_user_to_change_password_on_first_login": "Richiedi all'utente di cambiare password al primo accesso", + "rescan": "Scansiona nuovamente", "reset": "Ripristina", "reset_password": "Ripristina password", "reset_people_visibility": "Ripristina visibilità persone", @@ -1107,18 +1123,22 @@ "search": "Cerca", "search_albums": "Cerca album", "search_by_context": "Cerca con contesto", + "search_by_description": "Ricerca per descrizione", + "search_by_description_example": "Giornata di escursioni a Sapa", "search_by_filename": "Cerca per nome del file o estensione", "search_by_filename_example": "es. IMG_1234.JPG o PNG", "search_camera_make": "Cerca produttore fotocamera...", "search_camera_model": "Cerca modello fotocamera...", "search_city": "Cerca città...", "search_country": "Cerca paese...", + "search_for": "Cerca per", "search_for_existing_person": "Cerca per persona esistente", "search_no_people": "Nessuna persona", "search_no_people_named": "Nessuna persona chiamate \"{name}\"", "search_options": "Opzioni Ricerca", "search_people": "Cerca persone", "search_places": "Cerca luoghi", + "search_rating": "Cerca per valutazione...", "search_settings": "Cerca Impostazioni", "search_state": "Cerca stato...", "search_tags": "Cerca tag...", @@ -1165,6 +1185,7 @@ "shared_from_partner": "Foto da {partner}", "shared_link_options": "Opzioni link condiviso", "shared_links": "Link condivisi", + "shared_links_description": "Condividi foto e video con un link", "shared_photos_and_videos_count": "{assetCount, plural, other {# foto & video condivisi.}}", "shared_with_partner": "Condiviso con {partner}", "sharing": "Condivisione", @@ -1187,6 +1208,7 @@ "show_person_options": "Mostra opzioni persona", "show_progress_bar": "Mostra Barra Avanzamento", "show_search_options": "Mostra impostazioni di ricerca", + "show_shared_links": "Mostra link condivisi", "show_slideshow_transition": "Mostra la transizione della presentazione", "show_supporter_badge": "Medaglia di Contributore", "show_supporter_badge_description": "Mostra la medaglia di contributore", @@ -1240,6 +1262,7 @@ "tag_created": "Tag creata: {tag}", "tag_feature_description": "Navigazione foto e video raggruppati per argomenti tag logici", "tag_not_found_question": "Non riesci a trovare un tag? Creane uno nuovo.", + "tag_people": "Tagga persone", "tag_updated": "Tag {tag} aggiornata", "tagged_assets": "{count, plural, one {# asset etichettato} other {# asset etichettati}}", "tags": "Tag", @@ -1274,11 +1297,13 @@ "unfavorite": "Rimuovi preferito", "unhide_person": "Mostra persona", "unknown": "Sconosciuto", + "unknown_country": "Paese sconosciuto", "unknown_year": "Anno sconosciuto", "unlimited": "Illimitato", "unlink_motion_video": "Scollega video in movimento", "unlink_oauth": "Scollega OAuth", "unlinked_oauth_account": "Scollega account OAuth", + "unmute_memories": "Disattiva l'audio dei ricordi", "unnamed_album": "Album senza nome", "unnamed_album_delete_confirmation": "Sei sicuro di voler eliminare questo album?", "unnamed_share": "Condivisione senza nome", @@ -1319,7 +1344,7 @@ "variables": "Variabili", "version": "Versione", "version_announcement_closing": "Il tuo amico, Alex", - "version_announcement_message": "Ehilà! È stata rilasciata una nuova versione di Immich. Leggi le note di rilascio e assicurati che i tuoi file docker-compose.yml/.env siano aggiornati per evitare problemi e incongruenze, soprattutto se utilizzi WatchTower o altri strumenti per aggiornare Immich in automatico.", + "version_announcement_message": "Ehilà! È stata rilasciata una nuova versione di Immich. Leggi le release notes e assicurati che i tuoi file di configurazione siano aggiornati per evitare problemi e incongruenze, soprattutto se utilizzi WatchTower o altri strumenti per aggiornare Immich in automatico.", "version_history": "Storico delle Versioni", "version_history_item": "Versione installata {version} il {date}", "video": "Video", @@ -1332,6 +1357,7 @@ "view_all": "Vedi tutto", "view_all_users": "Visualizza tutti gli utenti", "view_in_timeline": "Visualizza in timeline", + "view_link": "Visualizza link", "view_links": "Visualizza i link", "view_name": "Visualizza", "view_next_asset": "Visualizza risorsa successiva", @@ -1348,4 +1374,4 @@ "yes": "Si", "you_dont_have_any_shared_links": "Non è presente alcun link condiviso", "zoom_image": "Ingrandisci immagine" -} +} \ No newline at end of file diff --git a/i18n/ja.json b/i18n/ja.json index 98054f14ca..154e6be42e 100644 --- a/i18n/ja.json +++ b/i18n/ja.json @@ -256,7 +256,7 @@ "transcoding_max_b_frames": "最大Bフレーム", "transcoding_max_b_frames_description": "値を高くすると圧縮効率が向上しますが、エンコード速度が遅くなります。古いデバイスのハードウェアアクセラレーションでは対応していない場合があります。\"0\" はBフレームを無効にし、\"-1\" はこの値を自動的に設定します。", "transcoding_max_bitrate": "最大ビットレート", - "transcoding_max_bitrate_description": "最大ビットレートを設定すると、品質にわずかな影響を与えながらも、ファイルサイズを予測しやすくなります。720pの場合、一般的な値は VP9 や HEVC で \"2600k\"、H.264 で \"4500k\" です。\"0\" に設定すると無効になります。", + "transcoding_max_bitrate_description": "最大ビットレートを設定すると、品質にわずかな影響を与えながらも、ファイルサイズを予測しやすくなります。720pの場合、一般的な値は VP9 や HEVC で \"2600 kbit/s\"、H.264 で \"4500 kbit/s\" です。\"0\" に設定すると無効になります。", "transcoding_max_keyframe_interval": "最大キーフレーム間隔", "transcoding_max_keyframe_interval_description": "キーフレーム間の最大フレーム間隔を設定します。値を低くすると圧縮効率が悪化しますが、シーク時間が改善され、動きの速いシーンの品質が向上する場合があります。\"0\" に設定すると、この値が自動的に設定されます。", "transcoding_optimal_description": "設定解像度を超える動画、または容認されていない形式の動画", @@ -1257,4 +1257,4 @@ "yes": "はい", "you_dont_have_any_shared_links": "共有リンクはありません", "zoom_image": "画像を拡大" -} +} \ No newline at end of file diff --git a/i18n/ko.json b/i18n/ko.json index b19d85246d..29971e5e30 100644 --- a/i18n/ko.json +++ b/i18n/ko.json @@ -20,7 +20,7 @@ "add_partner": "파트너 추가", "add_path": "경로 추가", "add_photos": "사진 추가", - "add_to": "앨범에 추가...", + "add_to": "앨범에 추가…", "add_to_album": "앨범에 추가", "add_to_shared_album": "공유 앨범에 추가", "add_url": "URL 추가", @@ -68,7 +68,7 @@ "image_prefer_embedded_preview": "포함된 미리 보기 선호", "image_prefer_embedded_preview_setting_description": "가능한 경우 이미지 처리 시 RAW 사진에 포함된 미리 보기를 사용합니다. 포함된 미리 보기는 카메라에서 생성된 것으로 카메라마다 품질이 다릅니다. 일부 이미지의 경우 더 정확한 색상이 표현될 수 있지만 반대로 더 많은 아티팩트가 있을 수도 있습니다.", "image_prefer_wide_gamut": "넓은 색 영역 선호", - "image_prefer_wide_gamut_setting_description": "섬네일 이미지에 Display P3을 사용합니다. 많은 색상을 표현할 수 있어 더 정확한 표현이 가능하지만, 오래된 브라우저를 사용하는 경우 이미지가 다르게 보일 수 있습니다. 색상 왜곡을 방지하기 위해 sRGB 이미지는 이 설정이 적용되지 않습니다.", + "image_prefer_wide_gamut_setting_description": "섬네일 이미지에 Display P3를 사용합니다. 많은 색상을 표현할 수 있어 더 정확한 표현이 가능하지만, 오래된 브라우저를 사용하는 경우 이미지가 다르게 보일 수 있습니다. 색상 왜곡을 방지하기 위해 sRGB 이미지는 이 설정이 적용되지 않습니다.", "image_preview_description": "메타데이터를 제거한 중간 크기의 이미지, 단일 항목을 보는 경우 및 기계 학습에 사용됨", "image_preview_quality_description": "1부터 100 사이의 미리보기 품질. 값이 높을수록 좋지만 파일 크기가 커져 앱의 반응성이 떨어질 수 있으며, 값이 낮으면 기계 학습의 품질이 떨어질 수 있습니다.", "image_preview_title": "미리보기 설정", @@ -131,7 +131,7 @@ "machine_learning_smart_search_description": "CLIP 임베딩으로 자연어를 사용하여 이미지 검색", "machine_learning_smart_search_enabled": "스마트 검색 활성화", "machine_learning_smart_search_enabled_description": "비활성화된 경우 스마트 검색을 위한 이미지 처리를 진행하지 않습니다.", - "machine_learning_url_description": "기계 학습 서버 URL", + "machine_learning_url_description": "기계 학습 서버의 URL을 입력합니다. URL이 여러 개인 경우 첫 번째 서버부터 마지막까지 성공적으로 응답할 때까지 한 번에 하나씩 순서대로 요청을 시도합니다. 응답하지 않는 서버는 다시 사용 가능할 때까지 일시적으로 제외됩니다.", "manage_concurrency": "동시성 관리", "manage_log_settings": "로그 설정 관리", "map_dark_style": "다크 스타일", @@ -219,11 +219,11 @@ "reset_settings_to_default": "설정을 기본값으로 복원", "reset_settings_to_recent_saved": "마지막으로 저장된 설정으로 복원", "scanning_library": "라이브러리 스캔 중", - "search_jobs": "작업 검색...", + "search_jobs": "작업 검색…", "send_welcome_email": "환영 이메일 전송", "server_external_domain_settings": "외부 도메인", "server_external_domain_settings_description": "공개 공유 링크에 사용할 도메인 (http(s):// 포함)", - "server_public_users": "공공 사용자", + "server_public_users": "모든 사용자", "server_public_users_description": "공유 앨범에 사용자를 추가할 경우 모든 사용자(이름, 이메일)가 나열됩니다. 비활성화 할 경우, 관리자만이 사용자 목록을 사용할 수 있습니다.", "server_settings": "서버 설정", "server_settings_description": "서버 설정 관리", @@ -250,6 +250,10 @@ "storage_template_user_label": "사용자의 스토리지 레이블: {label}", "system_settings": "시스템 설정", "tag_cleanup_job": "태그 정리", + "template_email_if_empty": "비어 있는 경우 기본 템플릿이 사용됩니다.", + "template_email_preview": "미리보기", + "template_email_settings": "이메일 템플릿", + "template_email_settings_description": "사용자 정의 이메일 템플릿 관리", "theme_custom_css_settings": "사용자 정의 CSS", "theme_custom_css_settings_description": "Immich에 적용할 사용자 정의 CSS(Cascading Style Sheets) 설정", "theme_settings": "테마 설정", @@ -287,7 +291,7 @@ "transcoding_max_b_frames": "최대 B 프레임", "transcoding_max_b_frames_description": "값이 높으면 압축 효율이 향상되지만 인코딩 속도가 저하됩니다. 오래된 기기의 하드웨어 가속과 호환되지 않을 수 있습니다. 0을 입력한 경우 B 프레임을 비활성화하며, -1을 입력한 경우 자동으로 설정합니다.", "transcoding_max_bitrate": "최대 비트레이트", - "transcoding_max_bitrate_description": "최대 비트레이트를 지정하면 품질이 일부 저하되지만 파일 크기가 예측 가능한 수준으로 일정하게 유지됩니다. 일반적으로 720p 기준 VP9 및 HEVC는 2600k, H.264는 4500k를 사용합니다. 0을 입력한 경우 비활성화됩니다.", + "transcoding_max_bitrate_description": "최대 비트레이트를 지정하면 품질이 일부 저하되지만 파일 크기가 예측 가능한 수준으로 일정하게 유지됩니다. 일반적으로 720p 기준 VP9 및 HEVC는 2600 kbit/s, H.264는 4500 kbit/s를 사용합니다. 0을 입력한 경우 비활성화됩니다.", "transcoding_max_keyframe_interval": "최대 키프레임 간격", "transcoding_max_keyframe_interval_description": "키프레임 사이 최대 프레임 거리를 설정합니다. 값이 낮으면 압축 효율이 저하되지만 검색 시간이 개선되고 빠른 움직임이 있는 장면에서 품질이 향상됩니다. 0을 입력한 경우 자동으로 설정합니다.", "transcoding_optimal_description": "목표 해상도보다 높은 동영상 또는 허용되지 않는 형식의 동영상", @@ -392,17 +396,17 @@ "are_these_the_same_person": "동일한 인물인가요?", "are_you_sure_to_do_this": "계속 진행하시겠습니까?", "asset_added_to_album": "앨범에 추가되었습니다.", - "asset_adding_to_album": "앨범에 추가 중...", + "asset_adding_to_album": "앨범에 추가 중…", "asset_description_updated": "항목의 설명이 업데이트되었습니다.", "asset_filename_is_offline": "{filename} 항목 누락됨", "asset_has_unassigned_faces": "항목에 할당되지 않은 얼굴이 있음", - "asset_hashing": "해시 확인 중...", + "asset_hashing": "해싱 중…", "asset_offline": "누락된 항목", "asset_offline_description": "디스크에서 항목을 더이상 찾을 수 없습니다. 서버 관리자에게 연락하여 도움을 받으세요.", "asset_skipped": "건너뜀", "asset_skipped_in_trash": "휴지통의 항목", "asset_uploaded": "업로드 완료", - "asset_uploading": "업로드 중...", + "asset_uploading": "업로드 중…", "assets": "항목", "assets_added_count": "항목 {count, plural, one {#개} other {#개}}가 추가되었습니다.", "assets_added_to_album_count": "앨범에 항목 {count, plural, one {#개} other {#개}} 추가됨", @@ -1080,6 +1084,8 @@ "search": "검색", "search_albums": "앨범 검색", "search_by_context": "내용 검색", + "search_by_description": "설명으로 검색", + "search_by_description_example": "사파에서 즐기는 하이킹", "search_by_filename": "파일명 또는 확장자로 검색", "search_by_filename_example": "예시: IMG_1234.JPG or PNG", "search_camera_make": "카메라 제조사 검색...", @@ -1221,6 +1227,7 @@ "they_will_be_merged_together": "선택한 인물들이 병합됩니다.", "third_party_resources": "서드 파티 리소스", "time_based_memories": "시간 기준 추억", + "timeline": "타임라인", "timezone": "시간대", "to_archive": "보관함으로 이동", "to_change_password": "비밀번호 변경", @@ -1243,6 +1250,7 @@ "unfavorite": "즐겨찾기 해제", "unhide_person": "인물 숨김 해제", "unknown": "알 수 없음", + "unknown_country": "알 수 없는 지역", "unknown_year": "알 수 없는 연도", "unlimited": "무제한", "unlink_motion_video": "모션 비디오 링크 해제", @@ -1279,6 +1287,7 @@ "user_purchase_settings_description": "구매 및 제품 키 관리", "user_role_set": "{user}님에게 {role} 역할을 설정했습니다.", "user_usage_detail": "사용자 사용량 상세", + "user_usage_stats_description": "계정 사용량 통계 보기", "username": "계정명", "users": "사용자", "utilities": "도구", @@ -1286,7 +1295,7 @@ "variables": "변수", "version": "버전", "version_announcement_closing": "당신의 친구, Alex가", - "version_announcement_message": "안녕하세요, 새 버전의 Immich를 사용할 수 있습니다. 자세한 내용은 릴리스 노트를 참조하세요. WatchTower 등의 자동 업데이트 기능을 사용하는 경우 의도하지 않은 동작을 방지하기 위해 docker-compose.yml.env 구성이 최신인지 확인하세요.", + "version_announcement_message": "안녕하세요! 새 버전의 Immich를 사용할 수 있습니다. 잘못된 구성을 방지하고 Immich를 최신 상태로 유지하기 위해 잠시 시간을 내어 릴리스 노트를 읽어보는 것을 권장합니다. 특히 WatchTower 등의 자동 업데이트 기능을 사용하는 경우 의도하지 않은 동작을 방지하기 위해 더더욱 권장됩니다.", "version_history": "버전 기록", "version_history_item": "{date} 버전 {version} 설치", "video": "동영상", @@ -1314,4 +1323,4 @@ "yes": "네", "you_dont_have_any_shared_links": "생성한 공유 링크가 없습니다.", "zoom_image": "이미지 확대" -} +} \ No newline at end of file diff --git a/i18n/lt.json b/i18n/lt.json index d998d33e01..738dc7bc7f 100644 --- a/i18n/lt.json +++ b/i18n/lt.json @@ -20,7 +20,7 @@ "add_partner": "Pridėti partnerį", "add_path": "Pridėti kelią", "add_photos": "Pridėti nuotraukų", - "add_to": "Pridėti į ...", + "add_to": "Pridėti į…", "add_to_album": "Pridėti į albumą", "add_to_shared_album": "Pridėti į bendrinamą albumą", "add_url": "Pridėti URL", @@ -50,6 +50,7 @@ "create_job": "Sukurti darbą", "cron_expression": "Cron išraiška", "cron_expression_description": "Nustatyti skanavimo intervalą naudojant cron formatą. Norėdami gauti daugiau informacijos žiūrėkite Crontab Guru", + "cron_expression_presets": "Išankstiniai Cron nustatymai", "disable_login": "Išjungti prisijungimą", "duplicate_detection_job_description": "Vykdykite mašininį mokymąsi panašių vaizdų aptikimui. Priklauso nuo išmaniosios paieškos", "exclusion_pattern_description": "Išimčių šablonai leidžia nepaisyti failų ir aplankų skenuojant jūsų biblioteką. Tai yra naudinga, jei turite aplankų su failais, kurių nenorite importuoti, pavyzdžiui, RAW failai.", @@ -142,6 +143,8 @@ "map_settings": "Žemėlapis", "map_settings_description": "Tvarkyti žemėlapio parametrus", "map_style_description": "URL į style.json žemėlapio temą", + "memory_cleanup_job": "Atsiminimų valymas", + "memory_generate_job": "Atsiminimų generavimas", "metadata_extraction_job": "Metaduomenų nuskaitymas", "metadata_extraction_job_description": "Kiekvieno bibliotekos elemento metaduomenų nuskaitymas, tokių kaip GPS koordinatės, veidai ar rezoliucija", "metadata_faces_import_setting": "Įjungti veidų importą", @@ -904,6 +907,8 @@ "removed_from_archive": "Pašalinta iš archyvo", "removed_from_favorites": "Pašalinta iš mėgstamiausių", "removed_from_favorites_count": "{count, plural, one {# pašalintas} few {# pašalinti} other {# pašalinta}} iš mėgstamiausių", + "removed_memory": "Atsiminimas pašalintas", + "removed_photo_from_memory": "Nuotrauka ištrinta iš atsiminimų", "removed_tagged_assets": "Žyma pašalinta iš {count, plural, one {# elemento} other {# elementų}}", "rename": "Pervadinti", "repair": "Pataisyti", @@ -932,6 +937,7 @@ "search": "Ieškoti", "search_albums": "", "search_by_context": "Ieškoti pagal kontekstą", + "search_by_description_example": "Žygio diena Sapoje", "search_by_filename": "Ieškoti pagal failo pavadinimą arba plėtinį", "search_by_filename_example": "pvz. IMG_1234.JPG arba PNG", "search_camera_make": "", diff --git a/i18n/lv.json b/i18n/lv.json index 3d64db2b9f..d962ae1bd1 100644 --- a/i18n/lv.json +++ b/i18n/lv.json @@ -20,7 +20,7 @@ "add_partner": "Pievienot partneri", "add_path": "Pievienot ceļu", "add_photos": "Pievienot fotoattēlus", - "add_to": "Pievienot ..", + "add_to": "Pievienot…", "add_to_album": "Pievienot albumam", "add_to_shared_album": "Pievienot koplietotam albumam", "add_url": "Pievienot URL", @@ -49,7 +49,7 @@ "external_library_management": "Ārējo bibliotēku pārvaldība", "face_detection": "Seju noteikšana", "image_format": "Formāts", - "image_format_description": "", + "image_format_description": "WebP veido mazākus failus nekā JPEG, taču to kodēšana ir lēnāka.", "image_prefer_embedded_preview": "", "image_prefer_embedded_preview_setting_description": "", "image_prefer_wide_gamut": "", @@ -299,12 +299,13 @@ "asset_offline": "", "asset_uploading": "Augšupielādē...", "assets": "aktīvi", - "authorized_devices": "", + "authorized_devices": "Autorizētās ierīces", "back": "Atpakaļ", "backward": "", "birthdate_saved": "Dzimšanas datums veiksmīgi saglabāts", "birthdate_set_description": "Dzimšanas datums tiek izmantots, lai aprēķinātu šīs personas vecumu fotogrāfijas uzņemšanas brīdī.", "blurred_background": "", + "bugs_and_feature_requests": "Kļūdas un funkciju pieprasījumi", "camera": "", "camera_brand": "", "camera_model": "", @@ -326,12 +327,13 @@ "clear": "Notīrīt", "clear_all": "Notīrīt visu", "clear_message": "", - "clear_value": "", + "clear_value": "Notīrīt vērtību", + "clockwise": "Pulksteņrādītāja virzienā", "close": "Aizvērt", "collapse": "Sakļaut", "collapse_all": "Sakļaut visu", "color": "Krāsa", - "color_theme": "", + "color_theme": "Krāsu tēma", "comment_deleted": "Komentārs dzēsts", "comment_options": "", "comments_are_disabled": "", @@ -841,13 +843,14 @@ "toggle_theme": "", "total_usage": "Kopējais lietojums", "trash": "Atkritne", - "trash_all": "", + "trash_all": "Dzēst Visu", "trash_no_results_message": "", "type": "", "unarchive": "Atarhivēt", "unfavorite": "Noņemt no izlases", "unhide_person": "Atcelt personas slēpšanu", "unknown": "", + "unknown_country": "Nezināma Valsts", "unknown_year": "Nezināms gads", "unlimited": "Neierobežots", "unlink_oauth": "", @@ -857,7 +860,7 @@ "unselect_all": "", "unstack": "At-Stekot", "up_next": "", - "updated_password": "", + "updated_password": "Parole ir atjaunināta", "upload": "Augšupielādēt", "upload_concurrency": "", "upload_status_duplicates": "Dublikāti", @@ -868,7 +871,7 @@ "user": "Lietotājs", "user_id": "Lietotāja ID", "user_usage_detail": "Informācija par lietotāju lietojumu", - "username": "", + "username": "Lietotājvārds", "users": "Lietotāji", "utilities": "Rīki", "validate": "", @@ -879,15 +882,16 @@ "video": "Videoklips", "video_hover_setting_description": "", "videos": "Videoklipi", + "view_album": "Skatīt Albumu", "view_all": "Apskatīt visu", - "view_all_users": "", + "view_all_users": "Skatīt visus lietotājus", "view_links": "", "view_next_asset": "", "view_previous_asset": "", "waiting": "Gaida", - "week": "", + "week": "Nedēļa", "welcome_to_immich": "", - "year": "", + "year": "Gads", "years_ago": "Pirms {years, plural, one {# gada} other {# gadiem}}", "yes": "Jā", "zoom_image": "Pietuvināt attēlu" diff --git a/i18n/mk.json b/i18n/mk.json index 0bfbcfefe9..658ab9453e 100644 --- a/i18n/mk.json +++ b/i18n/mk.json @@ -1,28 +1,295 @@ { - "about": "Освежи", + "about": "За Immich", "account": "Профил", "account_settings": "Поставки за профилот", - "acknowledge": "Означи како прочитано", + "acknowledge": "Прочитано", "action": "Акција", "actions": "Акции", "active": "Активни", - "activity": "Активности", - "add": "Додај", - "add_a_description": "Додај опис", - "add_a_location": "Додај локација", - "add_a_name": "Додај име", - "add_a_title": "Додај наслов", - "add_exclusion_pattern": "Додај патерн за игнотирање", - "add_import_path": "Додај патека за импортирање", - "add_location": "Додај локација", - "add_more_users": "Додај уште корисници", - "add_partner": "Додај партнер", - "add_path": "Додај патека", - "add_photos": "Додај слики", - "add_to": "Додај во...", - "add_to_album": "Додај во албум", - "add_to_shared_album": "Додај во споделен албум", + "activity": "Активност", + "activity_changed": "Активноста е {enabled, select, true {овозможена} other {неовозможена}}", + "add": "Додади", + "add_a_description": "Додади опис", + "add_a_location": "Додади локација", + "add_a_name": "Додади име", + "add_a_title": "Додади наслов", + "add_exclusion_pattern": "Додади шаблон за исклучување", + "add_import_path": "Додади патека за импортирање", + "add_location": "Додади локација", + "add_more_users": "Додади уште корисници", + "add_partner": "Додади партнер", + "add_path": "Додади патека", + "add_photos": "Додади слики", + "add_to": "Додади во…", + "add_to_album": "Додади во албум", + "add_to_shared_album": "Додади во споделен албум", + "add_url": "Додади URL", "added_to_archive": "Додадено во архива", "added_to_favorites": "Додадено во омилени", - "added_to_favorites_count": "Додадени {count, number} во омилени" + "added_to_favorites_count": "Додадени {count, number} во омилени", + "admin": { + "add_exclusion_pattern_description": "Додади шаблони за исклучување. Поддржано е користење на glob со *, **, и ?. За да се игнорираат сите датотеки во кој било директориум именуван \"Raw\", користи \"**/Raw/**\". За да се игнорираат сите датотеки што завршуваат со \".tif\", користи \"**/*.tif\". За да се игнорира апсолутна патека, користи \"/path/to/ignore/**\".", + "asset_offline_description": "Ова средство од екстерна библиотека веќе не е пронајдено на дискот и е преместено во ѓубре. Ако датотеката била преместена во рамките на библиотеката, проверете ја вашата временска линија за новото соодветно средство. За да го вратите ова средство, осигурајте се дека долунаведената патека може да биде пристапена од Immich и скенирајте ја библиотеката.", + "authentication_settings": "Поставки за автентикација", + "authentication_settings_description": "Управувај со лозинки, OAuth, и други поставки за автентикација", + "authentication_settings_disable_all": "Дали сте сигурни дека сакате да ги исклучите сите методи за најава? Целосно ќе биде оневозможено најавување.", + "authentication_settings_reenable": "За повторно да овозможите, искористете Сервер команда.", + "background_task_job": "Позадински задачи", + "backup_database": "Резервна копија од базата на податоци", + "backup_database_enable_description": "Овозможи резервни копии од базата на податоци", + "backup_keep_last_amount": "Количина на претходни резервни копии за чување", + "backup_settings": "Поставки за резервни копии", + "backup_settings_description": "Управувај со поставки за резервни копии на базата на податоци", + "check_all": "Провери сѐ", + "cleared_jobs": "Исчистени задачи за: {job}", + "config_set_by_file": "Конгигурацијата е моментално поставена од конфигурациска датотека", + "confirm_delete_library": "Дали сте сигурни дека сакате да ја избришете библиотеката {library}?", + "confirm_delete_library_assets": "Дали сте сигурни дека сакате да ја избришете оваа библиотека? Ова ќе {count, plural, one {избрише # содржано средство} other {ги избрише сите # содржани средства}} од Immich и нема да може да се {count, plural, one {врати} other {вратат}} назад. Датотеките ќе останат на диск.", + "confirm_email_below": "За да потврдите, внесете \"{email}\" доле", + "confirm_reprocess_all_faces": "Дали сте сигурни дека сакате да се обработат одново сите лица? Ова ќе ги избрише и сите именувани луѓе.", + "confirm_user_password_reset": "Дали сте сигурни дека сакате да се поништи лозинката на {user}?", + "create_job": "Создади задача", + "cron_expression": "Cron израз", + "cron_expression_description": "Подеси го интервалот на скенирање користејќи го cron форматот. За повеќе информации погледнете на пр. Crontab Guru", + "cron_expression_presets": "Предефинирани Cron изрази", + "disable_login": "Оневозможи најава", + "duplicate_detection_job_description": "Пушти машинско учење на средствата за да се откријат слични слики. Се потпира на Smart Search", + "force_delete_user_warning": "ПРЕДУПРЕДУВАЊЕ: Ова веднаш ќе го отстрани корисникот и сите средства. Оваа акција не може да се поништи и датотеките нема да може да се вратат назад.", + "image_format": "Формат", + "image_quality": "Квалитет", + "image_resolution": "Резолуција", + "image_settings": "Поставки за слики", + "library_scanning": "Периодично скенирање", + "library_settings": "Екстерна библиотека", + "logging_enable_description": "Вклучи евидентирање", + "logging_settings": "Евидентирање", + "map_dark_style": "Темен стил", + "map_light_style": "Светол стил", + "map_settings": "Карта", + "metadata_extraction_job": "Извлечи метаподатоци", + "migration_job": "Миграција", + "oauth_auto_launch": "Автоматско започнување", + "oauth_auto_register": "Автоматска регистрација", + "oauth_button_text": "Текст на копче", + "oauth_client_id": "Клиентски ID", + "oauth_client_secret": "Клиентска тајна", + "oauth_issuer_url": "URL на издавач", + "oauth_scope": "Опсег", + "oauth_settings": "OAuth", + "oauth_signing_algorithm": "Алгоритам за потпишување", + "offline_paths": "Офлајн патеки", + "password_settings": "Најава со лозинка", + "repair_all": "Поправи ги сите", + "sidecar_job": "Sidecar метаподатоци", + "storage_template_settings": "Шаблон за складирање", + "system_settings": "Системски поставки", + "thumbnail_generation_job": "Генерирај сликички", + "transcoding_acceleration_vaapi": "VAAPI", + "transcoding_threads": "Нишки", + "untracked_files": "Неследени датотеки" + }, + "admin_email": "Администрациска Е-пошта", + "admin_password": "Администрациска лозинка", + "administration": "Администрација", + "advanced": "Напредно", + "albums": "Албуми", + "all": "Сите", + "all_people": "Сите луѓе", + "anti_clockwise": "Спротивно од стрелките на часовникот", + "appears_in": "Се појавува во", + "archive": "Архива", + "archive_size": "Големина на архива", + "asset_hashing": "Хеширање…", + "asset_offline": "Средството е офлајн", + "asset_skipped": "Пропуштено", + "assets": "Средства", + "authorized_devices": "Авторизирани уреди", + "back": "Назад", + "backward": "Наназад", + "blurred_background": "Заматена позадина", + "camera": "Камера", + "camera_brand": "Марка на камера", + "camera_model": "Модел на камера", + "cancel": "Откажи", + "cancel_search": "Откажи пребарување", + "change_password": "Промени лозинка", + "city": "Град", + "clear": "Исчисти", + "clear_all": "Исчисти сѐ", + "clockwise": "Во насока на стрелките на часовникот", + "close": "Затвори", + "collapse": "Колапс", + "collapse_all": "Колапсирај сѐ", + "color": "Боја", + "comment_options": "Опции за коментар", + "confirm": "Потврди", + "confirm_password": "Потврди лозинка", + "contain": "Во рамки на прозорецот", + "context": "Контекст", + "continue": "Продолжи", + "copy_image": "Копирај слика", + "copy_link": "Копирај линк", + "country": "Држава", + "cover": "Покриј го прозорецот", + "covers": "Насловни", + "create": "Создади", + "create_album": "Создади албум", + "create_link": "Создади линк", + "created": "Создадено", + "current_device": "Тековен уред", + "dark": "Темно", + "day": "Ден", + "delete": "Избриши", + "delete_link": "Избриши линк", + "description": "Опис", + "details": "Детали", + "direction": "Насока", + "disabled": "Оневозможено", + "discord": "Дискорд", + "discover": "Откриј", + "display_options": "Опции за приказ", + "documentation": "Документација", + "done": "Готово", + "download": "Превземи", + "download_settings": "Превземање", + "downloading": "Се превземува", + "duplicates": "Дупликати", + "duration": "Времетраење", + "edit": "Уреди", + "edit_date": "Датум на уредување", + "edit_faces": "Уреди лица", + "edit_link": "Уреди линк", + "edit_location": "Уреди локација", + "edit_people": "Уреди луѓе", + "edit_user": "Уреди корисник", + "edited": "Уредено", + "editor": "Уредувач", + "editor_crop_tool_h2_rotation": "Ротација", + "email": "Е-пошта", + "empty_trash": "Испразни го ѓубрето", + "enable": "Овозможи", + "enabled": "Овозможено", + "end_date": "Краен датум", + "error": "Грешка", + "exif": "Exif", + "expand_all": "Прошири ги сите", + "expire_after": "Да истече после", + "expired": "Истечено", + "explore": "Истражи", + "export": "Извези", + "extension": "Екстензија", + "external": "Екстерно", + "external_libraries": "Екстерни библиотеки", + "face_unassigned": "Недоделено", + "favorite": "Омилено", + "favorites": "Омилени", + "features": "Функии", + "file_name": "Име на датотека", + "filename": "Име на датотека", + "filetype": "Тип на датотека", + "filter_people": "Филтрирај луѓе", + "folders": "Папки", + "forward": "Нанапред", + "general": "Генерално", + "get_help": "Побарај помош", + "go_back": "Врати се назад", + "hide_password": "Скриј лозинка", + "host": "Хост", + "hour": "Час", + "image": "Слика", + "in_archive": "Во архива", + "individual_share": "Индивидуално споделување", + "info": "Информации", + "jobs": "Задачи", + "keep": "Задржи", + "language": "Јазик", + "last_seen": "Последно видено", + "latitude": "Географска ширина", + "leave": "Напушти", + "level": "Ниво", + "library": "Библиотека", + "light": "Светло", + "link_options": "Опции за линк", + "list": "Листа", + "loading": "Вчитување", + "log_out": "Одјави се", + "login": "Најава", + "longitude": "Географска должина", + "look": "Изглед", + "make": "Марка", + "map": "Карта", + "matches": "Софпаѓања", + "media_type": "Тип на медија", + "memories": "Мемории", + "memory": "Меморија", + "menu": "Мени", + "merge": "Спој", + "minimize": "Минимизирај", + "minute": "Минута", + "missing": "Недостасувачки", + "model": "Модел", + "month": "Месец", + "more": "Уште", + "name": "Име", + "never": "Никогаш", + "new_password": "Нова лозинка", + "new_person": "Нова личност", + "next": "Следно", + "no": "Не", + "no_name": "Без име", + "no_results": "Нема резултати", + "notes": "Белешки", + "notifications": "Нотификации", + "oauth": "OAuth", + "offline": "Офлајн", + "ok": "Ок", + "online": "Онлајн", + "options": "Опции", + "or": "или", + "original": "оригинално", + "other": "Друго", + "other_devices": "Други уреди", + "other_variables": "Други променливи", + "password": "Лозинка", + "people": "Луѓе", + "permanently_delete": "Трајни избриши", + "photos": "Слики", + "place": "Место", + "preset": "Претходно поставено", + "preview": "Преглед", + "reaction_options": "Опции за реакција", + "read_changelog": "Прочитај дневник на промени", + "refresh": "Освежи", + "refreshed": "Освежено", + "remove": "Отстрани", + "repair": "Поправи", + "require_password": "Потребно лозинка", + "reset": "Ресетирај", + "restore": "Поврати", + "role": "Улога", + "save": "Зачувај", + "search": "Пребарај", + "second": "Секунда", + "selected": "Избрано", + "settings": "Поставки", + "share": "Сподели", + "sharing": "Споделување", + "slideshow": "Слајдшоу", + "state": "Регион", + "suggestions": "Предлози", + "sync": "Синхронизација", + "template": "Шаблон", + "to_archive": "Архива", + "to_favorite": "Додади во омилени", + "trash": "Ѓубре", + "unarchive": "Извади од архива", + "unfavorite": "Извади од омилени", + "unknown": "Непознато", + "users": "Korisnici", + "utilities": "Алатки", + "variables": "Променливи", + "video": "Видео", + "waiting": "Во исчекување", + "week": "Недела", + "year": "Година" } diff --git a/i18n/mr.json b/i18n/mr.json index 0967ef424b..ec05d6c702 100644 --- a/i18n/mr.json +++ b/i18n/mr.json @@ -1 +1,62 @@ -{} +{ + "about": "विषयी", + "account": "खाते", + "account_settings": "खाते व्यवस्था", + "acknowledge": "मान्यता", + "action": "कृती", + "actions": "कृत्ये", + "active": "सक्रिय", + "activity": "गतिविधि", + "activity_changed": "गतिविधि {enabled, select, true {enabled} other {disabled}}", + "add": "जोडा", + "add_a_description": "वर्णन करा", + "add_a_location": "एक स्थळ टाका", + "add_a_name": "नाव टाका", + "add_a_title": "शीर्षक टाका", + "add_exclusion_pattern": "अपवाद नमुना जोडा", + "add_import_path": "आयात मार्ग टाका", + "add_location": "स्थळ टाका", + "add_more_users": "अधिक वापरकर्ते जोडा", + "add_partner": "भागीदार जोडा", + "add_path": "मार्ग टाका", + "add_photos": "छायाचित्रे जोडा", + "add_to": "त्या मध्ये जोडा…", + "add_to_album": "संग्रहात टाका", + "add_to_shared_album": "सामायिक संग्रहात टाका", + "add_url": "URL जोडा", + "added_to_archive": "संग्रहालयात जोडले", + "added_to_favorites": "आवडत्यात टाकले", + "added_to_favorites_count": "आवडत्यात {count, number} टाकले", + "admin": { + "add_exclusion_pattern_description": "अपवाद अनुकूलन जोडा. ** आणि ? या उपयोगात ग्लोबिंग समर्थित आहे. कोणत्याही \"Raw\" नावाच्या निर्देशिकेमधील सर्व खतावण्या दुर्लक्षीत करण्यासाठी \"/Raw/\" वापरा. \".tif\" या सामान्य पथावर समाप्त असलेल्या सर्व खतावण्या दुर्लक्षीत करण्यासाठी \"**/.tif\" वापरा. विशिष्ट पथ दुर्लक्ष करण्यासाठी \"/path/to/ignore/**\" वापरा.", + "asset_offline_description": "ही बाह्य संग्रहालय संसाधने डिस्कवर नाहीत आणि ट्रॅशमध्ये विस्थापित केली गेली आहेत. जर फाइल संग्रहालयामध्ये विस्थापित केली गेली आहे, तर नवीन संगत संसाधन किंव्हा रोजीनिशी मध्ये तपासा. हा संसाधन वापर करण्यासाठी कृपया निम्नलिखित खतावणी पथाला इम्मीच द्वारा वापरू शकतो याची तपासणी करा आणि तो संग्रहालय चाळा.", + "authentication_settings": "प्रमाणीकरण साधक", + "authentication_settings_description": "परवलीचा शब्द, OAuth आणि अन्य प्रमाणीकरण प्रबंधन करा", + "authentication_settings_disable_all": "तुम्हाला खात्री आहे की तुम्ही सर्व प्रवेश पद्धती बंद करू इच्छिता? प्रवेश पूर्णपणे बंद होइल!.", + "authentication_settings_reenable": "परत चालू करण्यासाठी Server Command वापरा.", + "background_task_job": "पृष्ठभूमि कार्य", + "backup_database": "माहिती संचयाची सुरक्षित प्रत करा", + "backup_database_enable_description": "माहिती संचयाच्या प्रतिलिपी चालू करा", + "backup_keep_last_amount": "पूर्वीच्या किती प्रतिलिपी ठेवायच्या", + "backup_settings": "प्रतिलिपी व्यवस्था", + "backup_settings_description": "माहिती संचय प्रतिलिपी व्यवस्थापन", + "check_all": "सर्व तपासा", + "cleared_jobs": "{job}: च्या कार्यवाह्या काढल्या", + "config_set_by_file": "संरचना सध्या संरचना खतावणीद्वारे निश्चित केली आहे", + "confirm_delete_library": "तुम्हाला नक्की हे {library} संग्रहालय हटवायचे आहे का?", + "confirm_delete_library_assets": "तुम्हाला नक्की हे संग्रहालय हटवायचे आहे का? इम्मीच मधून {count, plural, one {# contained asset} other {all # contained assets}} काढले जातील, आणि पूर्ववत करता येणार नाहीत. छायाचित्रे डिस्क वर राहतील.", + "confirm_email_below": "पुष्टी करण्या साठी, खाली \"{email}\" टंकलिखित करा", + "confirm_reprocess_all_faces": "तुम्हाला खात्री आहे का की तुम्हाला सर्व चेहऱ्यांवर पुन्हा प्रक्रिया करायची आहे? यामुळे नाव दिलेले लोकही साफ होतील.", + "confirm_user_password_reset": "तुम्हाला नक्की {user} चा परवलीचा शब्द बदलायचा आहे का?", + "create_job": "कार्य बनवा", + "cron_expression": "वेळापत्रक सूत्र", + "cron_expression_description": "चाळन्याचे वेळापत्रक क्रॉन पद्धती ने करा. अधिक माहिती साठी पहा: क्रॉन गुरु", + "cron_expression_presets": "पूर्वनिर्धारित वेळापत्रक सूत्रे", + "disable_login": "प्रवेशाधिकर वर्ज्य करा", + "duplicate_detection_job_description": "सारख्या छायाचित्रांचा शोध घेण्यासाठी यांत्रिकी प्रशिक्षण द्या. ही कार्यक्षमता चतुर शोधप्रणालीवर अवलंबून आहे", + "exclusion_pattern_description": "आपले संग्रहालय चाळताना अपवाद नमुने आपल्याला खतावण्या आणि र्निर्देशिकेला दुर्लक्षीत करू देतात. आपल्याकडे कच्च्या खतावण्या सारख्या आयात करू इच्छित नसलेल्या असंपादित (RAW) खतावण्या असलेल्या निर्देशिका असल्यास हे उपयुक्त आहे.", + "external_library_created_at": "बाह्य संग्रहालय ({date} रोजी बनवले गेले)", + "external_library_management": "बाह्य संग्रहालय व्यवस्थापन", + "face_detection": "मुख संशोधन" + } +} diff --git a/i18n/ms.json b/i18n/ms.json index d03ef614b4..7da863750d 100644 --- a/i18n/ms.json +++ b/i18n/ms.json @@ -20,13 +20,13 @@ "add_partner": "Tambah rakan", "add_path": "Tambah laluan", "add_photos": "Tambah gambar", - "add_to": "Tambah ke...", + "add_to": "Tambah ke…", "add_to_album": "Tambah ke album", "add_to_shared_album": "Tambah ke album yang dikongsi", "add_url": "Tambah URL", "added_to_archive": "Tambah ke arkib", - "added_to_favorites": "Ditambah pada favorit", - "added_to_favorites_count": "Menambahkan {count, number} ke favorit", + "added_to_favorites": "Ditambah ke kegemaran", + "added_to_favorites_count": "Menambahkan {count, number} ke kegemaran", "admin": { "add_exclusion_pattern_description": "Tambahkan corak pengecualian. Globbing menggunakan *, **, dan ? disokong. Untuk mengabaikan semua fail dalam mana-mana direktori bernama \"Raw\", gunakan \"**/Raw/**\". Untuk mengabaikan semua fail yang berakhir dengan \".tif\", gunakan \"**/*.tif\". Untuk mengabaikan laluan mutlak, gunakan \"/path/to/ignore/**\".", "asset_offline_description": "Aset pustaka luaran ini tidak lagi ditemui pada cakera dan telah dialihkan ke sampah. Jika fail telah dialihkan dalam pustaka, semak garis masa anda untuk aset baharu yang sepadan. Untuk memulihkan aset ini, sila pastikan bahawa laluan fail di bawah boleh diakses oleh Immich dan mengimbas pustaka.", @@ -219,7 +219,7 @@ "reset_settings_to_default": "Tetapkan semula tetapan kepada lalai", "reset_settings_to_recent_saved": "Tetapkan semula tetapan kepada tetapan yang disimpan baru-baru ini", "scanning_library": "Mengimbas perpustakaan", - "search_jobs": "Cari kerja...", + "search_jobs": "Cari kerja…", "send_welcome_email": "Hantar e-mel alu-aluan", "server_external_domain_settings": "Domain luaran", "server_external_domain_settings_description": "Domain untuk pautan kongsi awam, termasuk http(s)://", @@ -230,6 +230,7 @@ "server_welcome_message": "Mesej alu-aluan", "server_welcome_message_description": "Mesej yang dipaparkan pada halaman log masuk.", "sidecar_job": "Metadata kereta sisi", + "sidecar_job_description": "Temui atau segerakkan metadata sampingan daripada sistem fail", "slideshow_duration_description": "Bilangan saat untuk memaparkan setiap imej", "smart_search_job_description": "Jalankan pembelajaran mesin pada aset-aset untuk menyokong carian pintar", "storage_template_date_time_description": "Cap masa penciptaan aset digunakan untuk maklumat masa dan tarikh", @@ -242,10 +243,85 @@ "storage_template_migration_info": "Perubahan templat hanya akan digunakan pada aset baharu. Untuk menggunakan templat secara retroaktif pada aset-aset yang dimuat naik sebelum ini, jalankan {job}.", "storage_template_migration_job": "Kerja Migrasi Templat Storan", "storage_template_more_details": "Untuk butiran lanjut tentang ciri ini, rujuk kepada Templat Storan dan implikasi", + "storage_template_onboarding_description": "Apabila didayakan, ciri ini akan menyusun fail secara automatik berdasarkan templat yang ditentukan pengguna. Disebabkan isu kestabilan, ciri ini telah dimatikan secara umum. Untuk mendapatkan maklumat lanjut, sila lihat dokumentasi.", + "storage_template_path_length": "Anggaran kepanjangan laluan: {length, number}/{limit, number}", "storage_template_settings": "Templat Storan", + "storage_template_settings_description": "Urus struktur folder dan nama fail aset dimuat naik", + "storage_template_user_label": "{label} ialah Label Storan pengguna", + "system_settings": "Tetapan Sistem", + "tag_cleanup_job": "Pembersihan tag", + "template_email_available_tags": "Anda boleh menggunakan pembolehubah berikut dalam templat anda: {tags}", + "template_email_if_empty": "Jika templat kosong, e-mel yang terpilih sebelum ini akan digunakan.", + "template_email_invite_album": "Templat Jemputan Album", + "template_email_preview": "Previu", + "template_email_settings": "Templat E-mel", + "template_email_settings_description": "Templat urus pemberitahuan dengan e-mel tersuai", + "template_email_update_album": "Templat Kemas kini Album", + "template_email_welcome": "Templat e-mel alu-aluan", + "template_settings": "Templat Pemberitahuan", + "template_settings_description": "Urus templat tersuai untuk pemberitahuan.", + "theme_custom_css_settings": "CSS tersuai", + "theme_custom_css_settings_description": "Lembaran Gaya Lata membolehkan reka bentuk Immich disuaikan.", + "theme_settings": "Tetapan Tema", "theme_settings_description": "Urus penyesuaian antara muka web Immich", + "these_files_matched_by_checksum": "Fail ini dipadankan dengan semakan mereka", "thumbnail_generation_job": "Jana Imej Kenit", - "thumbnail_generation_job_description": "Janakan imej kenit yang besar, kecil, dan kabur untuk setiap aset, serta imej kenit untuk setiap orang" + "thumbnail_generation_job_description": "Janakan imej kenit yang besar, kecil, dan kabur untuk setiap aset, serta imej kenit untuk setiap orang", + "transcoding_acceleration_api": "API Pecutan", + "transcoding_acceleration_api_description": "API yang akan berinteraksi dengan peranti anda untuk mempercepatkan transcoding. Tetapan ini adalah 'usaha terbaik': ia akan berundur kepada transkod perisian apabila gagal. VP9 mungkin berfungsi atau tidak bergantung pada perkakasan anda.", + "transcoding_acceleration_nvenc": "NVENC (memerlukan GPU NVIDIA)", + "transcoding_acceleration_qsv": "Pensegerakan Pantas (memerlukan CPU Intel generasi ke-7 atau lebih baru)", + "transcoding_acceleration_rkmpp": "RKMPP (hanya pada SOC Rockchip)", + "transcoding_acceleration_vaapi": "VAAPI", + "transcoding_accepted_audio_codecs": "Codec audio yang diterima", + "transcoding_accepted_audio_codecs_description": "Pilih codec audio yang tidak perlu ditranskodkan. Hanya digunakan untuk dasar transkod tertentu.", + "transcoding_accepted_containers": "Bekas yang diterima", + "transcoding_accepted_containers_description": "Pilih format bekas yang tidak perlu ditukar semula kepada MP4. Hanya digunakan untuk dasar transkod tertentu.", + "transcoding_accepted_video_codecs": "Codec video yang diterima", + "transcoding_accepted_video_codecs_description": "Pilih codec video yang tidak perlu ditranskodkan. Hanya digunakan untuk dasar transkod tertentu.", + "transcoding_advanced_options_description": "Pilihan yang tidak perlu diubah untuk kebanyakan pengguna", + "transcoding_audio_codec": "Codec audio", + "transcoding_audio_codec_description": "Opus ialah pilihan kualiti tertinggi, tetapi mempunyai keserasian yang lebih rendah dengan peranti atau perisian lama.", + "transcoding_bitrate_description": "Video yang lebih tinggi daripada kadar bit maksimum atau tidak dalam format yang diterima", + "transcoding_codecs_learn_more": "Untuk mengetahui lebih lanjut tentang istilah yang digunakan di sini, rujuk dokumentasi FFmpeg untuk codec H.264, codec HEVC dan codec VP9.", + "transcoding_constant_quality_mode": "Mod kualiti berterusan", + "transcoding_constant_quality_mode_description": "ICQ lebih baik daripada CQP, tetapi ada beberapa peranti pecutan perkakasan tidak menyokong mod ini. Menetapkan pilihan ini akan memilih mod yang ditentukan apabila menggunakan pengekodan berasaskan kualiti. Diabaikan oleh NVENC kerana ia tidak menyokong ICQ.", + "transcoding_constant_rate_factor": "Faktor kadar malar (-crf)", + "transcoding_constant_rate_factor_description": "Tahap kualiti video. Nilai biasa ialah 23 untuk H.264, 28 untuk HEVC, 31 untuk VP9 dan 35 untuk AV1. Lebih rendah adalah lebih baik, tetapi menghasilkan fail yang lebih besar.", + "transcoding_disabled_description": "Jangan transcode mana-mana video, boleh memecahkan main balik pada sesetengah pelanggan", + "transcoding_encoding_options": "Pilihan Pengekodan", + "transcoding_encoding_options_description": "Tetapkan codec, resolusi, kualiti dan pilihan lain untuk video yang dikodkan", + "transcoding_hardware_acceleration": "Pecutan Perkakasan", + "transcoding_hardware_acceleration_description": "Eksperimen; lebih pantas, tetapi akan mempunyai kualiti yang lebih rendah pada kadar bit yang sama", + "transcoding_hardware_decoding": "Penyahkodan perkakasan", + "transcoding_hardware_decoding_setting_description": "Mendayakan pecutan hujung ke hujung dan bukannya hanya mempercepatkan pengekodan. Mungkin tidak berfungsi pada semua video.", + "transcoding_hevc_codec": "Codec HEVC", + "transcoding_max_b_frames": "Bingkai-B maksimum", + "transcoding_max_b_frames_description": "Nilai yang lebih tinggi meningkatkan kecekapan mampatan, tetapi memperlahankan pengekodan. Mungkin tidak serasi dengan pecutan perkakasan pada peranti lama. 0 melumpuhkan bingkai B, manakala -1 menetapkan nilai ini secara automatik.", + "transcoding_max_bitrate": "Kadar bit maksimum", + "transcoding_max_bitrate_description": "Menetapkan kadar bit maksima boleh menjadikan saiz fail lebih boleh diramal dengan kekurangan yang kecil kepada kualiti. Pada 720p, nilai biasa ialah 2600 kbit/s untuk VP9 atau HEVC, atau 4500 kbit/s untuk H.264. Dilumpuhkan jika ditetapkan kepada 0.", + "transcoding_max_keyframe_interval": "Selangan keyframe maksimum", + "transcoding_max_keyframe_interval_description": "Menetapkan jarak bingkai maksimum antara keyframes. Nilai yang lebih rendah memburukkan kecekapan mampatan, tetapi menambah baik masa carian dan mungkin meningkatkan kualiti dalam adegan dengan pergerakan pantas. 0 menetapkan nilai ini secara automatik.", + "transcoding_optimal_description": "Video yang lebih tinggi daripada resolusi sasaran atau tidak dalam format yang diterima", + "transcoding_policy": "Dasar Transkod", + "transcoding_policy_description": "Tetapkan masa bila video akan ditranskodkan", + "transcoding_preferred_hardware_device": "Pilihan peranti perkakasan", + "transcoding_preferred_hardware_device_description": "Terpakai hanya untuk VAAPI dan QSV. Menetapkan nod dri yang digunakan untuk transkod perkakasan.", + "transcoding_preset_preset": "Pratetap (-preset)", + "transcoding_preset_preset_description": "Kelajuan mampatan. Pratetap yang lebih perlahan menghasilkan fail yang lebih kecil dan meningkatkan kualiti apabila pada kadar bit tertentu. VP9 mengabaikan kelajuan di atas 'lebih cepat'.", + "transcoding_reference_frames": "Bingkai rujukan", + "transcoding_reference_frames_description": "Bilangan bingkai untuk dirujuk semasa memampatkan bingkai yang diberikan. Nilai yang lebih tinggi meningkatkan kecekapan mampatan, tetapi memperlahankan pengekodan. 0 menetapkan nilai ini secara automatik.", + "transcoding_required_description": "Hanya untuk video yang tidak dalam format yang diterima", + "transcoding_settings": "Tetapan Transkod Video", + "transcoding_settings_description": "Urus video yang hendak ditranskod dan cara memprosesnya", + "transcoding_target_resolution": "Resolusi sasaran", + "transcoding_target_resolution_description": "Peleraian yang lebih tinggi boleh mengekalkan lebih banyak butiran tetapi mengambil masa lebih lama untuk mengekod, mempunyai saiz fail yang lebih besar dan boleh mengurangkan responsif app.", + "transcoding_temporal_aq": "AQ sementara", + "transcoding_temporal_aq_description": "Terpakai hanya untuk NVEC. Meningkatkan kualiti adegan yang berperinci tinggi dan berpunya rendah gerakan. Mungkin tidak serasi dengan peranti lama.", + "transcoding_threads": "Benang", + "transcoding_threads_description": "Nilai yang lebih tinggi membawa kepada pengekodan yang lebih pantas, tetapi meninggalkan lebih sedikit ruang untuk pemproses tugas lain semasa aktif. Nilai ini tidak boleh lebih daripada bilangan teras CPU. Memaksimumkan penggunaan jika ditetapkan kepada 0.", + "transcoding_tone_mapping": "Pemetaan nada", + "transcoding_tone_mapping_description": "Percubaan untuk mengekalkan penampilan video HDR apabila ditukar kepada SDR. Setiap algoritma membuat pertukaran yang berbeza untuk warna, perincian dan kecerahan. Hable mengekalkan perincian, Mobius mengekalkan warna, dan Reinhard mengekalkan kecerahan." }, "deduplication_criteria_1": "Saiz imej dalam bait", "deduplication_criteria_2": "Kiraan data EXIF", @@ -286,6 +362,8 @@ "download_settings": "Muat Turun", "download_settings_description": "Urus tetapan yang berkaitan dengan muat turun aset", "downloading": "Memuat turun", + "search_by_description": "Carian secara huraian", + "search_by_description_example": "Hari mendaki di Sapa", "timeline": "Garis masa", "total": "Jumlah", "user_usage_stats": "Statistik penggunaan akaun", @@ -294,4 +372,4 @@ "yes": "Ya", "you_dont_have_any_shared_links": "Anda tidak mempunyai apa-apa pautan yang dikongsi", "zoom_image": "Zum Gambar" -} +} \ No newline at end of file diff --git a/i18n/nb_NO.json b/i18n/nb_NO.json index e8c63a696a..78f7941760 100644 --- a/i18n/nb_NO.json +++ b/i18n/nb_NO.json @@ -14,13 +14,13 @@ "add_a_name": "Legg til navn", "add_a_title": "Legg til tittel", "add_exclusion_pattern": "Legg til ekskluderingsmønster", - "add_import_path": "Legg til importbane", + "add_import_path": "Legg til importsti", "add_location": "Legg til sted", "add_more_users": "Legg til flere brukere", "add_partner": "Legg til partner", - "add_path": "Legg til bane", + "add_path": "Legg til sti", "add_photos": "Legg til bilder", - "add_to": "Legg til...", + "add_to": "Legg til…", "add_to_album": "Legg til album", "add_to_shared_album": "Legg til delt album", "add_url": "Legg til URL", @@ -28,9 +28,9 @@ "added_to_favorites": "Lagt til i favoritter", "added_to_favorites_count": "Lagt til {count, number} i favoritter", "admin": { - "add_exclusion_pattern_description": "Legg til ekskluderingsmønstre. Globbing med *, ** og ? støttes. For å ignorere alle filer i en hvilken som helst mappe som heter \"Raw\", bruk \"**/Raw/**\". For å ignorere alle filer som slutter på \".tif\", bruk \"**/*.tif\". For å ignorere en absolutt filplassering, bruk \"/filbane/til/ignorer/**\".", + "add_exclusion_pattern_description": "Legg til ekskluderingsmønstre. Globbing med *, ** og ? støttes. For å ignorere alle filer i en hvilken som helst mappe som heter \"Raw\", bruk \"**/Raw/**\". For å ignorere alle filer som slutter på \".tif\", bruk \"**/*.tif\". For å ignorere en absolutt filplassering, bruk \"/filsti/til/ignorer/**\".", "asset_offline_description": "Denne eksterne bibliotekressursen finnes ikke lenger på disk og har blitt flyttet til papirkurven. Hvis filen ble flyttet innad i biblioteket, sjekk tidslinjen din for den tilsvarende ressursen. For å gjenopprette ressursen, vennligst sørg for at filstien under er tilgjengelig for Immich og skan biblioteket.", - "authentication_settings": "Autentiserings innstillinger", + "authentication_settings": "Godkjenningsinnstillinger", "authentication_settings_description": "Administrer passord, OAuth, og andre innstillinger for autentisering", "authentication_settings_disable_all": "Er du sikker på at du ønsker å deaktivere alle innloggingsmetoder? Innlogging vil bli fullstendig deaktivert.", "authentication_settings_reenable": "For å aktivere på nytt, bruk en Server Command.", @@ -41,6 +41,7 @@ "backup_settings": "Backupinnstillinger", "backup_settings_description": "Håndter innstillinger for databasebackup", "check_all": "Merk Alle", + "cleanup": "Opprydding", "cleared_jobs": "Ryddet opp jobber for: {job}", "config_set_by_file": "Konfigurasjonen er for øyeblikket satt av en konfigurasjonsfil", "confirm_delete_library": "Er du sikker på at du vil slette biblioteket {library}?", @@ -58,10 +59,10 @@ "external_library_created_at": "Ekstern bibliotek (opprettet {date})", "external_library_management": "Administrasjon av eksterne biblioteker", "face_detection": "Ansiktsgjenkjennelse", - "face_detection_description": "Oppdag ansikter i filer ved hjelp av maskinlæring. For videoer vurderes bare miniatyrbildet. \"All\" (om-)behandler alle ressurser. \"Missing\" stiller opp ressurser som ikke har blitt behandlet ennå. Oppdagede ansikter vil bli stilt opp for ansiktsgjenkjenning etter at ansiktsgjenkjenning er fullført, og de grupperes i eksisterende eller nye personer.", - "facial_recognition_job_description": "Grupper oppdagede ansikter i personer. Denne trinn utføres etter at ansiktsgjenkjenning er fullført. \"All\" (om-)grupperer alle ansikter på nytt. \"Missing\" stiller opp ansikter som ikke har blitt tilordnet en person ennå.", - "failed_job_command": "Kommandoen {command} feilet for jobben: {job}", - "force_delete_user_warning": "ADVARSEL: Dette vil umiddelbart fjerne brukeren og alle eiendeler. Dette kan ikke angres, og filene kan ikke gjenopprettes.", + "face_detection_description": "Finn ansikter i bilder ved hjelp av maskinlæring. For videoer brukes bare miniatyrbildet. \"Alle\" går gjennom alle bilder (igjen). \"Tilbakestill\" fjerner all gjeldende ansiktsdata. \"Manglende\" legger til filer som ikke har blitt behandlet enda i køen. Oppdagede ansikter vil blir sendt til ansiktsgjenkjenning, og koblet til eksisterende eller nye personer.", + "facial_recognition_job_description": "Kobler oppdagede ansikt til personer. Dette utføres etter at ansiktssøk er fullført. \"Tilbakestill\" (om-)grupperer alle ansikt på nytt. \"Missing\" stiller opp ansikt som ikke har blitt tilordnet en person ennå.", + "failed_job_command": "Kommandoen {command} feilet for jobb: {job}", + "force_delete_user_warning": "ADVARSEL: Dette vil umiddelbart fjerne brukeren og alle data. Dette kan ikke angres, og filene kan ikke gjenopprettes.", "forcing_refresh_library_files": "Tvinger oppdatering av alle bibliotekfiler", "image_format": "Format", "image_format_description": "WebP gir mindre filer enn JPEG, men er tregere å lage.", @@ -96,13 +97,13 @@ "library_scanning_enable_description": "Aktiver periodisk skanning av bibliotek", "library_settings": "Eksternt bibliotek", "library_settings_description": "Administrer innstillinger for eksterne bibliotek", - "library_tasks_description": "Utfør bibliotekoppgaver", + "library_tasks_description": "Skann eksterne biblioteker for nye og/eller endrede ressurser", "library_watching_enable_description": "Overvåk eksterne bibliotek for filendringer", "library_watching_settings": "Overvåkning av bibliotek (EKSPERIMENTELL)", "library_watching_settings_description": "Se automatisk etter endrede filer", "logging_enable_description": "Aktiver logging", "logging_level_description": "Hvis aktivert, hvilket loggnivå som skal brukes.", - "logging_settings": "Logging", + "logging_settings": "Logger", "machine_learning_clip_model": "Clip-modell", "machine_learning_clip_model_description": "Navnet på en CLIP-modell finnes her. Merk at du må kjøre 'Smart Søk'-jobben på nytt for alle bilder etter at du har endret modell.", "machine_learning_duplicate_detection": "Duplikat-deteksjon", @@ -131,7 +132,7 @@ "machine_learning_smart_search_description": "Søk etter bilder semantisk ved å bruke CLIP-embeddings", "machine_learning_smart_search_enabled": "Aktiver smart søk", "machine_learning_smart_search_enabled_description": "Hvis deaktivert, vil bilder ikke bli enkodet for smart søk.", - "machine_learning_url_description": "URL til maskinlærings-serveren. Hvis mer enn en URL er lagt inn, hver server vill bli forsøkt en om gangen frem til en svarer suksessfullt, i rekkefølge fra først til sist.", + "machine_learning_url_description": "URL til maskinlærings-serveren. Hvis mer enn en URL er lagt inn, hver server vill bli forsøkt en om gangen frem til en svarer suksessfullt, i rekkefølge fra først til sist. Servere som ikke svarer vil midlertidig bli oversett frem til dem svarer igjen.", "manage_concurrency": "Administrer samtidighet", "manage_log_settings": "Administrer logginnstillinger", "map_dark_style": "Mørk stil", @@ -147,6 +148,8 @@ "map_settings": "Innstillinger for kart og GPS", "map_settings_description": "Administrer kartinnstillinger", "map_style_description": "URL til et style.json-karttema", + "memory_cleanup_job": "Minneopprydding", + "memory_generate_job": "Minnegenerering", "metadata_extraction_job": "Hent metadata", "metadata_extraction_job_description": "Hent metadatainformasjon fra hver fil, for eksempel GPS-posisjon og oppløsning", "metadata_faces_import_setting": "Aktiver ansikts importering", @@ -155,7 +158,7 @@ "metadata_settings_description": "Administrer metadatainnstillinger", "migration_job": "Migrering", "migration_job_description": "Migrer miniatyrbilder for filer og ansikter til den nyeste mappestrukturen", - "no_paths_added": "Ingen filbaner lagt til", + "no_paths_added": "Ingen filstier lagt til", "no_pattern_added": "Ingen mønster lagt til", "note_apply_storage_label_previous_assets": "Merk: For å bruke lagringsetiketten på tidligere opplastede filer, kjør", "note_cannot_be_changed_later": "MERK: Dette kan ikke endres senere!", @@ -201,12 +204,12 @@ "oauth_storage_quota_claim_description": "Sett automatisk brukerens lagringskvote til verdien av dette kravet.", "oauth_storage_quota_default": "Standard lagringskvote (GiB)", "oauth_storage_quota_default_description": "Kvote i GiB som skal brukes når ingen krav er oppgitt (Skriv 0 for ubegrenset kvote).", - "offline_paths": "Frakoblede filbaner", + "offline_paths": "Frakoblede filstier", "offline_paths_description": "Disse resultatene kan skyldes manuell sletting av filer som ikke er en del av et eksternt bibliotek.", "password_enable_description": "Logg inn med e-post og passord", "password_settings": "Passordinnlogging", "password_settings_description": "Administrer innstillinger for passordinnlogging", - "paths_validated_successfully": "Alle filbaner validert uten problemer", + "paths_validated_successfully": "Alle filstier validert uten problemer", "person_cleanup_job": "Person opprydding", "quota_size_gib": "Kvotestørrelse (GiB)", "refreshing_all_libraries": "Oppdaterer alle biblioteker", @@ -219,7 +222,7 @@ "reset_settings_to_default": "Tilbakestill innstillinger til standard", "reset_settings_to_recent_saved": "Tilbakestill innstillingene til de nylig lagrede innstillingene", "scanning_library": "Søk biblioteket", - "search_jobs": "Søk etter jobber...", + "search_jobs": "Søk etter jobber…", "send_welcome_email": "Send velkomst-e-post", "server_external_domain_settings": "Eksternt domene", "server_external_domain_settings_description": "Domene for offentlige delingslenker, inkludert http(s)://", @@ -240,7 +243,7 @@ "storage_template_hash_verification_enabled_description": "Aktiver hasjverifisering. Ikke deaktiver dette med mindre du er sikker på konsekvensene", "storage_template_migration": "Lagringsmal migrering", "storage_template_migration_description": "Bruk gjeldende {template} på tidligere opplastede bilder", - "storage_template_migration_info": "Malendringer vil kun gjelde nye ressurser. For å anvende malen på tidligere opplastede ressurser, kjør {job}.", + "storage_template_migration_info": "Lagringsmalen vil endre filtypen til små bokstaver. Malendringer vil kun gjelde nye ressurser. For å anvende malen på tidligere opplastede ressurser, kjør {job}.", "storage_template_migration_job": "Migreringsjobb for lagringsmal", "storage_template_more_details": "For mer informasjon om denne funksjonen, se lagringsmalen og dens konsekvenser", "storage_template_onboarding_description": "Når aktivert, vil denne funksjonen automatisk organisere filer basert på en brukerdefinert mal. På grunn av stabilitetsproblemer er funksjonen deaktivert som standard. For mer informasjon, se documentation.", @@ -299,7 +302,7 @@ "transcoding_max_b_frames": "Maksimalt antall B-frames", "transcoding_max_b_frames_description": "Høyere verdier forbedrer komprimeringseffektiviteten, men senker ned kodingen. Kan være inkompatibelt med maskinvareakselerasjon på eldre enheter. 0 deaktiverer B-rammer, mens -1 setter verdien automatisk.", "transcoding_max_bitrate": "Maksimal bithastighet", - "transcoding_max_bitrate_description": "Å sette en maksimal bithastighet kan gjøre filstørrelsene mer forutsigbare med en liten kostnad for kvaliteten. For 720p er typiske verdier 2600k for VP9 eller HEVC, eller 4500k for H.264. Deaktivert hvis satt til 0.", + "transcoding_max_bitrate_description": "Å sette en maksimal bithastighet kan gjøre filstørrelsene mer forutsigbare med en liten kostnad for kvaliteten. For 720p er typiske verdier 2600 kbit/s for VP9 eller HEVC, eller 4500 kbit/s for H.264. Deaktivert hvis satt til 0.", "transcoding_max_keyframe_interval": "Maksimal referansebilde intervall", "transcoding_max_keyframe_interval_description": "Setter maksimalt antall bilder mellom referansebilder. Lavere verdier reduserer kompresjonseffektiviteten, men forbedrer søketider og kan forbedre kvaliteten i scener med rask bevegelse. 0 setter verdien automatisk.", "transcoding_optimal_description": "Videoer som har høyere oppløsning enn målopppløsningen eller som ikke er i et akseptert format", @@ -362,7 +365,7 @@ "advanced": "Avansert", "age_months": "Alder {months, plural, one {# måned} other {# måneder}}", "age_year_months": "Alder 1 år, {months, plural, one {# måned} other {# måneder}}", - "age_years": "{years, plural, other {Age #}}", + "age_years": "{years, plural, other {Alder #}}", "album_added": "Album lagt til", "album_added_notification_setting_description": "Motta en e-postvarsling når du legges til i et delt album", "album_cover_updated": "Albumomslag oppdatert", @@ -391,11 +394,12 @@ "allow_edits": "Tillat redigering", "allow_public_user_to_download": "Tillat uautentiserte brukere å laste ned", "allow_public_user_to_upload": "Tillat uautentiserte brukere å laste opp", + "alt_text_qr_code": "QR-kodebilde", "anti_clockwise": "Mot klokken", "api_key": "API Nøkkel", "api_key_description": "Denne verdien vil vises kun én gang. Pass på å kopiere den før du lukker vinduet.", - "api_key_empty": "API Key-navnet bør ikke være tomt", - "api_keys": "API Nøkkler", + "api_key_empty": "API-nøkkelnavnet bør ikke være tomt", + "api_keys": "API-nøkler", "app_settings": "Appinstillinger", "appears_in": "Vises i", "archive": "Arkiver", @@ -406,22 +410,22 @@ "are_these_the_same_person": "Er disse samme person?", "are_you_sure_to_do_this": "Er du sikker på at du vil gjøre dette?", "asset_added_to_album": "Lagt til i album", - "asset_adding_to_album": "Legger til i album...", + "asset_adding_to_album": "Legger til i album…", "asset_description_updated": "Elementbeskrivelse har blitt oppdatert", "asset_filename_is_offline": "Element {filename} er offline", "asset_has_unassigned_faces": "Element har ikke-tilordnede ansikter", - "asset_hashing": "Hasher...", + "asset_hashing": "Hasher…", "asset_offline": "Fil utilgjengelig", "asset_offline_description": "Dette elementet er offline. Immich kan ikke aksessere dets lokasjon. Vennlist påse at elementet er tilgijengelig og skann så biblioteket på nytt.", "asset_skipped": "Hoppet over", "asset_skipped_in_trash": "I søppelbøtten", "asset_uploaded": "Lastet opp", - "asset_uploading": "Laster opp...", + "asset_uploading": "Laster opp…", "assets": "Filer", "assets_added_count": "Lagt til {count, plural, one {# element} other {# elementer}}", "assets_added_to_album_count": "Lagt til {count, plural, one {# asset} other {# assets}} i album", "assets_added_to_name_count": "Lagt til {count, plural, one {# asset} other {# assets}} i {hasName, select, true {{name}} other {new album}}", - "assets_count": "{count, plural, one {# asset} other {# assets}}", + "assets_count": "{count, plural, one {# fil} other {# filer}}", "assets_moved_to_trash_count": "Flyttet {count, plural, one {# asset} other {# assets}} til søppel", "assets_permanently_deleted_count": "Permanent slettet {count, plural, one {# asset} other {# assets}}", "assets_removed_count": "Slettet {count, plural, one {# asset} other {# assets}}", @@ -481,6 +485,7 @@ "comments_are_disabled": "Kommentarer er deaktivert", "confirm": "Bekreft", "confirm_admin_password": "Bekreft administratorpassord", + "confirm_delete_face": "Er du sikker på at du vil slette {name} sitt ansikt fra ativia?", "confirm_delete_shared_link": "Er du sikker på at du vil slette denne delte lenken?", "confirm_keep_this_delete_others": "Alle andre ressurser i denne stabelen vil bli slettet bortsett fra denne ressursen. Er du sikker på at du vil fortsette?", "confirm_password": "Bekreft passord", @@ -509,7 +514,7 @@ "create_new_person_hint": "Tildel valgte eiendeler til en ny person", "create_new_user": "Opprett ny bruker", "create_tag": "Lag tag", - "create_tag_description": "Lag en ny tag. For undertag, vennligst fullfør hele banen til taggen, inkludert forovervendt skråstrek.", + "create_tag_description": "Lag en ny tag. For undertag, vennligst fullfør hele stien til taggen, inkludert forovervendt skråstrek.", "create_user": "Opprett Bruker", "created": "Opprettet", "current_device": "Nåværende enhet", @@ -533,6 +538,7 @@ "delete_album": "Slett album", "delete_api_key_prompt": "Er du sikker på at du vil slette denne API-nøkkelen?", "delete_duplicates_confirmation": "Er du sikker på at du vil slette disse duplikatene permanent?", + "delete_face": "Slett ansik", "delete_key": "Slett nøkkel", "delete_library": "Slett bibliotek", "delete_link": "Slett lenke", @@ -595,11 +601,12 @@ "editor_crop_tool_h2_rotation": "Rotasjon", "email": "E-postadresse", "empty_trash": "Tøm papirkurv", - "empty_trash_confirmation": "Er du sikker på at du vil tømme søppelbøtte ? Dette vil slette alle filene i søppelbøtta permanent fra Immich.\nDu kan ikke angre denne handlingen!", + "empty_trash_confirmation": "Er du sikker på at du vil tømme søppelbøtta? Dette vil slette alle filene i søppelbøtta permanent fra Immich.\nDu kan ikke angre denne handlingen!", "enable": "Aktivere", "enabled": "Aktivert", "end_date": "Slutt dato", "error": "Feil", + "error_delete_face": "Feil ved sletting av ansikt fra aktivia", "error_loading_image": "Feil ved lasting av bilde", "error_title": "Feil - Noe gikk galt", "errors": { @@ -766,8 +773,10 @@ "go_to_folder": "Gå til mappe", "go_to_search": "Gå til søk", "group_albums_by": "Grupper album etter...", + "group_country": "Grupper etter land", "group_no": "Ingen gruppering", "group_owner": "Grupper etter eiere", + "group_places_by": "Grupper plasser etter...", "group_year": "Grupper etter år", "has_quota": "Har kvote", "hi_user": "Hei {name} ({email})", @@ -800,6 +809,7 @@ "include_shared_albums": "Inkluder delte album", "include_shared_partner_assets": "Inkluder delte partnerfiler", "individual_share": "Individuell deling", + "individual_shares": "Individuelle delinger", "info": "Info", "interval": { "day_at_onepm": "Hver dag klokken 13:00", @@ -809,7 +819,7 @@ }, "invite_people": "Inviter Personer", "invite_to_album": "Inviter til album", - "items_count": "{count, plural, one {# item} other {# items}}", + "items_count": "{count, plural, one {# gjenstand} other {# gjenstander}}", "jobs": "Oppgaver", "keep": "Behold", "keep_all": "Behold alle", @@ -822,6 +832,7 @@ "latest_version": "Siste versjon", "latitude": "Breddegrad", "leave": "Forlat", + "lens_model": "Objektiv", "let_others_respond": "La andre respondere", "level": "Nivå", "library": "Bibliotek", @@ -880,6 +891,7 @@ "month": "Måned", "more": "Mer", "moved_to_trash": "Flyttet til papirkurven", + "mute_memories": "Demp minner", "my_albums": "Mine album", "name": "Navn", "name_or_nickname": "Navn eller kallenavn", @@ -975,7 +987,7 @@ "permanently_deleted_asset": "Filen har blitt permanent slettet", "permanently_deleted_assets_count": "Permanent slett {count, plural, one {# asset} other {# assets}}", "person": "Person", - "person_hidden": "{name}{hidden, select, true { (hidden)} other {}}", + "person_hidden": "{name}{hidden, select, true { (skjult)} other {}}", "photo_shared_all_users": "Det ser ut som om du deler bildene med alle brukere eller det er ingen brukere å dele med.", "photos": "Bilder", "photos_and_videos": "Bilder & Videoer", @@ -984,6 +996,7 @@ "pick_a_location": "Velg et sted", "place": "Sted", "places": "Plasseringer", + "places_count": "{count, plural, one {{count, number} Place} other {{count, number} Places}}", "play": "Spill av", "play_memories": "Spill av minner", "play_motion_photo": "Spill av bevegelsesbilde", @@ -1034,7 +1047,7 @@ "purchase_settings_server_activated": "Produktnøkkel for server er administrert av administratoren", "rating": "Stjernevurdering", "rating_clear": "Slett vurdering", - "rating_count": "{count, plural, one {# star} other {# stars}}", + "rating_count": "{count, plural, one {# sjerne} other {# stjerner}}", "rating_description": "Hvis EXIF vurdering i informasjons panelet", "reaction_options": "Reaksjonsalternativer", "read_changelog": "Les endringslogg", @@ -1071,6 +1084,8 @@ "removed_from_archive": "Fjernet fra arkivet", "removed_from_favorites": "Fjernet fra favoritter", "removed_from_favorites_count": "{count, plural, other {Removed #}} fra favoritter", + "removed_memory": "Slettet minne", + "removed_photo_from_memory": "Slettet bilde fra minne", "removed_tagged_assets": "Fjern tag fra {count, plural, one {# asset} other {# assets}}", "rename": "Gi nytt navn", "repair": "Reparer", @@ -1079,6 +1094,7 @@ "repository": "Depot", "require_password": "Krev passord", "require_user_to_change_password_on_first_login": "Krev at brukeren endrer passord ved første pålogging", + "rescan": "Skann på nytt", "reset": "Tilbakestill", "reset_password": "Tilbakestill passord", "reset_people_visibility": "Tilbakestill personsynlighet", @@ -1107,18 +1123,22 @@ "search": "Søk", "search_albums": "Søk i album", "search_by_context": "Søk etter kontekst", + "search_by_description": "Søk etter beskrivelse", + "search_by_description_example": "Turdag i Sapa", "search_by_filename": "Søk etter filnavn og filtype", "search_by_filename_example": "f.eks. IMG_1234.JPG eller PNG", "search_camera_make": "Søk etter kameramerke...", "search_camera_model": "Søk etter kamera modell...", "search_city": "Søk etter by...", "search_country": "Søk etter land...", + "search_for": "Søk etter", "search_for_existing_person": "Søk etter eksisterende person", "search_no_people": "Ingen personer", "search_no_people_named": "Ingen personer med navnet \"{name}\"", "search_options": "Søke alternativer", "search_people": "Søk personer", "search_places": "Søk steder", + "search_rating": "Søk etter vurdering...", "search_settings": "Søke instillinger", "search_state": "Søk etter stat...", "search_tags": "Søk tags...", @@ -1141,7 +1161,7 @@ "select_photos": "Velg bilder", "select_trash_all": "Velg å flytte alt til papirkurven", "selected": "Valgt", - "selected_count": "{count, plural, other {# selected}}", + "selected_count": "{count, plural, other {# valgt}}", "send_message": "Send melding", "send_welcome_email": "Send velkomstmelding", "server_offline": "Server frakoblet", @@ -1165,6 +1185,7 @@ "shared_from_partner": "Bilder fra {partner}", "shared_link_options": "Alternativer for delte lenke", "shared_links": "Delte linker", + "shared_links_description": "Del bilder og videoer med lenke", "shared_photos_and_videos_count": "{assetCount, plural, other {# delte bilder og videoer.}}", "shared_with_partner": "Delt med {partner}", "sharing": "Deling", @@ -1187,6 +1208,7 @@ "show_person_options": "Vis personalternativer", "show_progress_bar": "Vis fremdriftslinje", "show_search_options": "Vis søkealternativer", + "show_shared_links": "Vis delte lenker", "show_slideshow_transition": "Vis overgang til lysbildefremvisning", "show_supporter_badge": "Supportermerke", "show_supporter_badge_description": "Vis et supportermerke", @@ -1240,6 +1262,7 @@ "tag_created": "Lag merke: {tag}", "tag_feature_description": "Bla gjennom bilder og videoer gruppert etter logiske merke-emner", "tag_not_found_question": "Finner du ikke en merke? Opprett en nytt merke.", + "tag_people": "Tag Folk", "tag_updated": "Oppdater merke: {tag}", "tagged_assets": "Merket {count, plural, one {# asset} other {# assets}}", "tags": "Merker", @@ -1270,15 +1293,17 @@ "trashed_items_will_be_permanently_deleted_after": "Elementer i papirkurven vil bli permanent slettet etter {days, plural, one {# dag} other {# dager}}.", "type": "Type", "unarchive": "Fjern fra arkiv", - "unarchived_count": "{count, plural, other {Unarchived #}}", + "unarchived_count": "{count, plural, other {uarkivert #}}", "unfavorite": "Fjern favoritt", "unhide_person": "Vis person", "unknown": "Ukjent", + "unknown_country": "Ukjent Land", "unknown_year": "Ukjent År", "unlimited": "Ubegrenset", "unlink_motion_video": "Koble fra bevegelsesvideo", "unlink_oauth": "Fjern kobling til OAuth", "unlinked_oauth_account": "Koblet fra OAuth-konto", + "unmute_memories": "Opphev demping av minner", "unnamed_album": "Navnløst album", "unnamed_album_delete_confirmation": "Er du sikker på at du vil slette dette albumet?", "unnamed_share": "Deling uten navn", @@ -1332,6 +1357,7 @@ "view_all": "Vis alle", "view_all_users": "Vis alle brukere", "view_in_timeline": "Vis i tidslinje", + "view_link": "Vis lenke", "view_links": "Vis lenker", "view_name": "Vis", "view_next_asset": "Vis neste fil", @@ -1348,4 +1374,4 @@ "yes": "Ja", "you_dont_have_any_shared_links": "Du har ingen delte lenker", "zoom_image": "Zoom Bilde" -} +} \ No newline at end of file diff --git a/i18n/nl.json b/i18n/nl.json index 62d87bb63e..55de209224 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -20,7 +20,7 @@ "add_partner": "Partner toevoegen", "add_path": "Pad toevoegen", "add_photos": "Foto's toevoegen", - "add_to": "Toevoegen aan...", + "add_to": "Toevoegen aan…", "add_to_album": "Aan album toevoegen", "add_to_shared_album": "Aan gedeeld album toevoegen", "add_url": "URL toevoegen", @@ -41,6 +41,7 @@ "backup_settings": "Back-up instellingen", "backup_settings_description": "Database back-up instellingen beheren", "check_all": "Controleer het logboek", + "cleanup": "Opruimen", "cleared_jobs": "Taken gewist voor: {job}", "config_set_by_file": "Instellingen worden momenteel beheerd door een configuratiebestand", "confirm_delete_library": "Weet je zeker dat je de bibliotheek {library} wilt verwijderen?", @@ -96,7 +97,7 @@ "library_scanning_enable_description": "Periodieke bibliotheekscan aanzetten", "library_settings": "Externe bibliotheek", "library_settings_description": "Externe bibliotheekinstellingen beheren", - "library_tasks_description": "Voer bibliotheek taken uit", + "library_tasks_description": "Scan externe bibliotheken op nieuwe en/of gewijzigde media", "library_watching_enable_description": "Externe bibliotheken monitoren op bestandswijzigingen", "library_watching_settings": "Bibliotheek monitoren (EXPERIMENTEEL)", "library_watching_settings_description": "Automatisch gewijzigde bestanden bijhouden", @@ -131,7 +132,7 @@ "machine_learning_smart_search_description": "Semantisch zoeken naar afbeeldingen met CLIP-embeddings", "machine_learning_smart_search_enabled": "Slim zoeken inschakelen", "machine_learning_smart_search_enabled_description": "Indien uitgeschakeld, worden afbeeldingen niet verwerkt voor slim zoeken.", - "machine_learning_url_description": "De URL van de machine learning server. Als er meer dan één URL is opgegeven, wordt elke server geprobeerd totdat er een succesvol reageert, op volgorde van eerste tot laatste.", + "machine_learning_url_description": "De URL van de machine learning server. Als er meer dan één URL is opgegeven, wordt elke server geprobeerd totdat er een succesvol reageert, op volgorde van eerste tot laatste. Servers die geen reactie geven zullen tijdelijk genegeerd worden tot zij terug online komen.", "manage_concurrency": "Beheer gelijktijdigheid", "manage_log_settings": "Beheer logboekinstellingen", "map_dark_style": "Donkere stijl", @@ -147,6 +148,8 @@ "map_settings": "Kaart", "map_settings_description": "Beheer kaartinstellingen", "map_style_description": "URL naar een style.json kaartthema", + "memory_cleanup_job": "Geheugen opschonen", + "memory_generate_job": "Geheugen genereren", "metadata_extraction_job": "Metadata ophalen", "metadata_extraction_job_description": "Metadata ophalen van iedere asset, zoals GPS, gezichten en resolutie", "metadata_faces_import_setting": "Gezichten importeren inschakelen", @@ -219,7 +222,7 @@ "reset_settings_to_default": "Instellingen teruggezet naar standaard", "reset_settings_to_recent_saved": "Instellingen zijn gereset naar de recent opgeslagen instellingen", "scanning_library": "Bibliotheek scannen", - "search_jobs": "Taak zoeken...", + "search_jobs": "Taak zoeken…", "send_welcome_email": "Stuur een welkomstmail", "server_external_domain_settings": "Extern domein", "server_external_domain_settings_description": "Domein voor openbaar gedeelde links, inclusief http(s)://", @@ -240,7 +243,7 @@ "storage_template_hash_verification_enabled_description": "Zet hashverificatie aan, schakel dit niet uit tenzij je zeker bent van de implicaties", "storage_template_migration": "Opslagtemplate migratie", "storage_template_migration_description": "Pas de huidige {template} toe op eerder geüploade assets", - "storage_template_migration_info": "Wijzigingen in de template worden alleen toegepast op nieuwe assets. Om de template met terugwerkende kracht toe te passen op eerder geüploade assets, voer de {job} uit.", + "storage_template_migration_info": "Wijzigingen in het opslag template worden alleen toegepast op nieuwe assets. Om de template met terugwerkende kracht toe te passen op eerder geüploade assets, voer je de {job} uit.", "storage_template_migration_job": "Opslagtemplate migratietaak", "storage_template_more_details": "Voor meer details over deze functie, bekijk de Opslagstemplate en de implicaties daarvan", "storage_template_onboarding_description": "Wanneer ingeschakeld, zal deze functie bestanden automatisch organiseren gebaseerd op een gebruiker-definieerd template. Gezien de stabiliteitsproblemen is de functie standaard uitgeschakeld. Voor meer informatie, bekijk de documentatie.", @@ -299,7 +302,7 @@ "transcoding_max_b_frames": "Maximum B-Frames", "transcoding_max_b_frames_description": "Hogere waarden verbeteren de compressie efficiëntie, maar vertragen de codering. Is mogelijk niet compatibel met hardwareversnelling op oudere apparaten. 0 schakelt B-frames uit, terwijl -1 deze waarde automatisch instelt.", "transcoding_max_bitrate": "Maximum bitrate", - "transcoding_max_bitrate_description": "Het instellen van een maximale bitrate kan de bestandsgrootte voorspelbaarder maken, tegen geringe kosten voor de kwaliteit. Bij 720p zijn de typische waarden 2600k voor VP9 of HEVC, of 4500k voor H.264. Uitgeschakeld indien ingesteld op 0.", + "transcoding_max_bitrate_description": "Het instellen van een maximale bitrate kan de bestandsgrootte voorspelbaarder maken, tegen geringe kosten voor de kwaliteit. Bij 720p zijn de typische waarden 2600 kbit/s voor VP9 of HEVC, of 4500 kbit/s voor H.264. Uitgeschakeld indien ingesteld op 0.", "transcoding_max_keyframe_interval": "Maximum keyframe interval", "transcoding_max_keyframe_interval_description": "Stelt de maximale frameafstand tussen keyframes in. Lagere waarden verslechteren de compressie efficiëntie, maar verbeteren de zoektijden en kunnen de kwaliteit verbeteren in scènes met snelle bewegingen. 0 stelt deze waarde automatisch in.", "transcoding_optimal_description": "Video's met een hogere resolutie dan de doelresolutie of niet in een geaccepteerd formaat", @@ -391,11 +394,12 @@ "allow_edits": "Bewerkingen toestaan", "allow_public_user_to_download": "Sta openbare gebruiker toe om te downloaden", "allow_public_user_to_upload": "Sta openbare gebruiker toe om te uploaden", + "alt_text_qr_code": "QR-codeafbeelding", "anti_clockwise": "Linksom", - "api_key": "API sleutel", + "api_key": "API key", "api_key_description": "Deze waarde wordt slechts één keer getoond. Zorg ervoor dat je deze kopieert voordat je het venster sluit.", - "api_key_empty": "De naam van uw API sleutel mag niet leeg zijn", - "api_keys": "API sleutels", + "api_key_empty": "De naam van uw API key mag niet leeg zijn", + "api_keys": "API keys", "app_settings": "App instellingen", "appears_in": "Komt voor in", "archive": "Archief", @@ -406,17 +410,17 @@ "are_these_the_same_person": "Zijn dit dezelfde personen?", "are_you_sure_to_do_this": "Weet je zeker dat je dit wilt doen?", "asset_added_to_album": "Toegevoegd aan album", - "asset_adding_to_album": "Toevoegen aan album...", + "asset_adding_to_album": "Toevoegen aan album…", "asset_description_updated": "Asset beschrijving is bijgewerkt", "asset_filename_is_offline": "Asset {filename} is offline", "asset_has_unassigned_faces": "Asset heeft niet-toegewezen gezichten", - "asset_hashing": "Hashen...", + "asset_hashing": "Hashen…", "asset_offline": "Asset offline", "asset_offline_description": "Deze externe asset is niet meer op de schijf te vinden. Neem contact op met de Immich beheerder voor hulp.", "asset_skipped": "Overgeslagen", "asset_skipped_in_trash": "In prullenbak", "asset_uploaded": "Geüpload", - "asset_uploading": "Uploaden...", + "asset_uploading": "Uploaden…", "assets": "Assets", "assets_added_count": "{count, plural, one {# asset} other {# assets}} toegevoegd", "assets_added_to_album_count": "{count, plural, one {# asset} other {# assets}} aan het album toegevoegd", @@ -442,7 +446,7 @@ "bulk_delete_duplicates_confirmation": "Weet je zeker dat je {count, plural, one {# duplicate asset} other {# duplicate assets}} in bulk wilt verwijderen? Dit zal de grootste asset van elke groep behouden en alle andere duplicaten permanent verwijderen. Je kunt deze actie niet ongedaan maken!", "bulk_keep_duplicates_confirmation": "Weet je zeker dat je {count, plural, one {# duplicate asset} other {# duplicate assets}} wilt behouden? Dit zal alle groepen met duplicaten oplossen zonder iets te verwijderen.", "bulk_trash_duplicates_confirmation": "Weet je zeker dat je {count, plural, one {# duplicate asset} other {# duplicate assets}} in bulk naar de prullenbak wilt verplaatsen? Dit zal de grootste asset van elke groep behouden en alle andere duplicaten naar de prullenbak verplaatsen.", - "buy": "Koop Immich", + "buy": "Immich kopen", "camera": "Camera", "camera_brand": "Cameramerk", "camera_model": "Cameramodel", @@ -452,9 +456,9 @@ "cannot_undo_this_action": "Je kunt deze actie niet ongedaan maken!", "cannot_update_the_description": "Kan de beschrijving niet bijwerken", "change_date": "Wijzig datum", - "change_expiration_time": "Wijzig verlooptijd", - "change_location": "Wijzig locatie", - "change_name": "Wijzig naam", + "change_expiration_time": "Verlooptijd wijzigen", + "change_location": "Locatie wijzigen", + "change_name": "Naam wijzigen", "change_name_successfully": "Naam succesvol gewijzigd", "change_password": "Wijzig wachtwoord", "change_password_description": "Dit is de eerste keer dat je inlogt op het systeem of er is een verzoek gedaan om je wachtwoord te wijzigen. Voer hieronder het nieuwe wachtwoord in.", @@ -481,6 +485,7 @@ "comments_are_disabled": "Opmerkingen zijn uitgeschakeld", "confirm": "Bevestigen", "confirm_admin_password": "Bevestig beheerder wachtwoord", + "confirm_delete_face": "Weet je zeker dat je {name} gezicht wilt verwijderen uit de asset?", "confirm_delete_shared_link": "Weet je zeker dat je deze gedeelde link wilt verwijderen?", "confirm_keep_this_delete_others": "Alle andere assets in de stack worden verwijderd, behalve deze. Weet je zeker dat je wilt doorgaan?", "confirm_password": "Bevestig wachtwoord", @@ -531,9 +536,10 @@ "default_locale_description": "Formatteer datums en getallen op basis van de landinstellingen van je browser", "delete": "Verwijderen", "delete_album": "Album verwijderen", - "delete_api_key_prompt": "Weet je zeker dat je deze API sleutel wilt verwijderen?", + "delete_api_key_prompt": "Weet je zeker dat je deze API key wilt verwijderen?", "delete_duplicates_confirmation": "Weet je zeker dat je deze duplicaten permanent wilt verwijderen?", - "delete_key": "Verwijder sleutel", + "delete_face": "Gezicht verwijderen", + "delete_key": "Verwijder key", "delete_library": "Verwijder bibliotheek", "delete_link": "Verwijder link", "delete_others": "Andere verwijderen", @@ -579,7 +585,7 @@ "edit_faces": "Gezichten bewerken", "edit_import_path": "Import-pad bewerken", "edit_import_paths": "Import-paden bewerken", - "edit_key": "Sleutel bewerken", + "edit_key": "Key bewerken", "edit_link": "Link bewerken", "edit_location": "Locatie bewerken", "edit_name": "Naam bewerken", @@ -600,6 +606,7 @@ "enabled": "Ingeschakeld", "end_date": "Einddatum", "error": "Fout", + "error_delete_face": "Fout bij verwijderen gezicht uit asset", "error_loading_image": "Fout bij laden afbeelding", "error_title": "Fout - Er is iets misgegaan", "errors": { @@ -631,7 +638,7 @@ "failed_to_load_asset": "Kan asset niet laden", "failed_to_load_assets": "Kan assets niet laden", "failed_to_load_people": "Kan mensen niet laden", - "failed_to_remove_product_key": "Er is een fout opgetreden bij het verwijderen van de product sleutel", + "failed_to_remove_product_key": "Er is een fout opgetreden bij het verwijderen van de licentiesleutel", "failed_to_stack_assets": "Fout bij stapelen van assets", "failed_to_unstack_assets": "Fout bij ontstapelen van assets", "import_path_already_exists": "Dit import-pad bestaat al.", @@ -660,7 +667,7 @@ "unable_to_connect_to_server": "Kan geen verbinding maken met server", "unable_to_copy_to_clipboard": "Kan niet naar klembord kopiëren, zorg ervoor dat je de pagina via https opent", "unable_to_create_admin_account": "Kan beheerdersaccount niet aanmaken", - "unable_to_create_api_key": "Kan geen nieuwe API sleutel aanmaken", + "unable_to_create_api_key": "Kan geen nieuwe API key aanmaken", "unable_to_create_library": "Kan bibliotheek niet aanmaken", "unable_to_create_user": "Kan geen gebruiker aanmaken", "unable_to_delete_album": "Kan album niet verwijderen", @@ -693,7 +700,7 @@ "unable_to_reassign_assets_new_person": "Kan assets niet opnieuw toewijzen aan een nieuw persoon", "unable_to_refresh_user": "Kan gebruiker niet vernieuwen", "unable_to_remove_album_users": "Kan gebruiker niet van album verwijderen", - "unable_to_remove_api_key": "Kan API sleutel niet verwijderen", + "unable_to_remove_api_key": "Kan API key niet verwijderen", "unable_to_remove_assets_from_shared_link": "Kan assets niet verwijderen uit gedeelde link", "unable_to_remove_deleted_assets": "Kan offline bestanden niet verwijderen", "unable_to_remove_library": "Kan bibliotheek niet verwijderen", @@ -706,7 +713,7 @@ "unable_to_restore_trash": "Kan niet herstellen uit prullenbak", "unable_to_restore_user": "Kan gebruiker niet herstellen", "unable_to_save_album": "Kan album niet opslaan", - "unable_to_save_api_key": "Kan API sleutel niet opslaan", + "unable_to_save_api_key": "Kan API key niet opslaan", "unable_to_save_date_of_birth": "Kan geboortedatum niet opslaan", "unable_to_save_name": "Kan naam niet opslaan", "unable_to_save_profile": "Kan profiel niet opslaan", @@ -766,8 +773,10 @@ "go_to_folder": "Ga naar map", "go_to_search": "Ga naar zoeken", "group_albums_by": "Groepeer albums op...", + "group_country": "Groepeer op land", "group_no": "Niet groeperen", "group_owner": "Groeperen op eigenaar", + "group_places_by": "Groepeer plaatsen op...", "group_year": "Groeperen op jaar", "has_quota": "Heeft limiet", "hi_user": "Hallo {name} ({email})", @@ -800,6 +809,7 @@ "include_shared_albums": "Toon gedeelde albums", "include_shared_partner_assets": "Toon assets van gedeelde partner", "individual_share": "Individuele deellink", + "individual_shares": "Individuele deellinks", "info": "Info", "interval": { "day_at_onepm": "Iedere dag om 13 uur", @@ -822,6 +832,7 @@ "latest_version": "Nieuwste versie", "latitude": "Breedtegraad", "leave": "Verlaten", + "lens_model": "Lens model", "let_others_respond": "Laat anderen reageren", "level": "Niveau", "library": "Bibliotheek", @@ -853,7 +864,7 @@ "manage_sharing_with_partners": "Beheer delen met partners", "manage_the_app_settings": "Beheer de appinstellingen", "manage_your_account": "Beheer je account", - "manage_your_api_keys": "Beheer je API sleutels", + "manage_your_api_keys": "Beheer je API keys", "manage_your_devices": "Beheer je ingelogde apparaten", "manage_your_oauth_connection": "Beheer je OAuth koppeling", "map": "Kaart", @@ -880,12 +891,13 @@ "month": "Maand", "more": "Meer", "moved_to_trash": "Naar de prullenbak verplaatst", + "mute_memories": "Herrinneringen dempen", "my_albums": "Mijn albums", "name": "Naam", "name_or_nickname": "Naam of gebruikersnaam", "never": "Nooit", "new_album": "Nieuw album", - "new_api_key": "Nieuwe API sleutel", + "new_api_key": "Nieuwe API key", "new_password": "Nieuw wachtwoord", "new_person": "Nieuw persoon", "new_user_created": "Nieuwe gebruiker aangemaakt", @@ -984,6 +996,7 @@ "pick_a_location": "Kies een locatie", "place": "Plaats", "places": "Plaatsen", + "places_count": "{count, plural, one {{count, number} Plaats} other {{count, number} Plaatsen}}", "play": "Afspelen", "play_memories": "Herinneringen afspelen", "play_motion_photo": "Bewegingsfoto afspelen", @@ -1003,19 +1016,19 @@ "purchase_account_info": "Supporter", "purchase_activated_subtitle": "Bedankt voor het ondersteunen van Immich en open-source software", "purchase_activated_time": "Geactiveerd op {date, date}", - "purchase_activated_title": "Je sleutel is succesvol geactiveerd", + "purchase_activated_title": "Je licentiesleutel is succesvol geactiveerd", "purchase_button_activate": "Activeren", "purchase_button_buy": "Kopen", "purchase_button_buy_immich": "Koop Immich", "purchase_button_never_show_again": "Nooit meer tonen", "purchase_button_reminder": "Herinner mij over 30 dagen", - "purchase_button_remove_key": "Sleutel verwijderen", + "purchase_button_remove_key": "Licentiesleutel verwijderen", "purchase_button_select": "Selecteren", - "purchase_failed_activation": "Activeren mislukt! Controleer je e-mail voor de juiste productsleutel!", + "purchase_failed_activation": "Activeren mislukt! Controleer je e-mail voor de juiste licentiesleutel!", "purchase_individual_description_1": "Voor een gebruiker", "purchase_individual_description_2": "Supporter badge", "purchase_individual_title": "Gebruiker", - "purchase_input_suggestion": "Heb je een productsleutel? Voer de sleutel hieronder in", + "purchase_input_suggestion": "Heb je een licentiesleutel? Voer deze hieronder in", "purchase_license_subtitle": "Koop Immich om de verdere ontwikkeling van de service te ondersteunen", "purchase_lifetime_description": "Levenslange aankoop", "purchase_option_title": "AANKOOP MOGELIJKHEDEN", @@ -1024,14 +1037,14 @@ "purchase_panel_title": "Steun het project", "purchase_per_server": "Per server", "purchase_per_user": "Per gebruiker", - "purchase_remove_product_key": "Verwijder product sleutel", - "purchase_remove_product_key_prompt": "Weet je zeker dat je de product sleutel wilt verwijderen?", - "purchase_remove_server_product_key": "Verwijder server product sleutel", - "purchase_remove_server_product_key_prompt": "Weet je zeker dat je de server product sleutel wilt verwijderen?", + "purchase_remove_product_key": "Verwijder licentiesleutel", + "purchase_remove_product_key_prompt": "Weet je zeker dat je de licentiesleutel wilt verwijderen?", + "purchase_remove_server_product_key": "Verwijder server licentiesleutel", + "purchase_remove_server_product_key_prompt": "Weet je zeker dat je de server licentiesleutel wilt verwijderen?", "purchase_server_description_1": "Voor de volledige server", "purchase_server_description_2": "Supporter badge", "purchase_server_title": "Server", - "purchase_settings_server_activated": "De productcode van de server wordt beheerd door de beheerder", + "purchase_settings_server_activated": "De licentiesleutel van de server wordt beheerd door de beheerder", "rating": "Ster waardering", "rating_clear": "Waardering verwijderen", "rating_count": "{count, plural, one {# ster} other {# sterren}}", @@ -1071,6 +1084,8 @@ "removed_from_archive": "Verwijderd uit archief", "removed_from_favorites": "Verwijderd uit favorieten", "removed_from_favorites_count": "{count, plural, other {# verwijderd}} uit favorieten", + "removed_memory": "Geheugen verwijderd", + "removed_photo_from_memory": "Foto verwijderd uit geheugen", "removed_tagged_assets": "Tag verwijderd van {count, plural, one {# asset} other {# assets}}", "rename": "Hernoemen", "repair": "Repareren", @@ -1079,6 +1094,7 @@ "repository": "Repository", "require_password": "Wachtwoord vereisen", "require_user_to_change_password_on_first_login": "Vereisen dat de gebruiker het wachtwoord wijzigt bij de eerste keer inloggen", + "rescan": "Herscannen", "reset": "Resetten", "reset_password": "Wachtwoord resetten", "reset_people_visibility": "Zichtbaarheid mensen resetten", @@ -1107,18 +1123,22 @@ "search": "Zoeken", "search_albums": "Zoek albums", "search_by_context": "Zoeken op context", + "search_by_description": "Zoeken op beschrijving", + "search_by_description_example": "Wandelen in Sapa", "search_by_filename": "Zoeken op bestandsnaam of -extensie", "search_by_filename_example": "b.v. IMG_1234.JPG of PNG", "search_camera_make": "Zoek cameramerk...", "search_camera_model": "Zoek cameramodel...", "search_city": "Zoek stad...", "search_country": "Zoek land...", + "search_for": "Zoeken naar", "search_for_existing_person": "Zoek naar bestaande persoon", "search_no_people": "Geen mensen", "search_no_people_named": "Geen mensen genaamd \"{name}\"", "search_options": "Zoekopties", "search_people": "Zoek mensen", "search_places": "Zoek plaatsen", + "search_rating": "Zoeken op beoordeling...", "search_settings": "Zoek instellingen", "search_state": "Zoek staat...", "search_tags": "Tags zoeken...", @@ -1165,6 +1185,7 @@ "shared_from_partner": "Foto's van {partner}", "shared_link_options": "Opties voor gedeelde links", "shared_links": "Gedeelde links", + "shared_links_description": "Deel foto's en video's via een link", "shared_photos_and_videos_count": "{assetCount, plural, other {# gedeelde foto's & video's.}}", "shared_with_partner": "Gedeeld met {partner}", "sharing": "Delen", @@ -1187,6 +1208,7 @@ "show_person_options": "Toon persoonopties", "show_progress_bar": "Toon voortgangsbalk", "show_search_options": "Zoekopties weergeven", + "show_shared_links": "Toon gedeelde links", "show_slideshow_transition": "Diavoorstellingsovergang tonen", "show_supporter_badge": "Supporter badge", "show_supporter_badge_description": "Toon een supporterbadge", @@ -1240,6 +1262,7 @@ "tag_created": "Tag aangemaakt: {tag}", "tag_feature_description": "Bladeren door foto's en video's gegroepeerd op tags", "tag_not_found_question": "Kun je een tag niet vinden? Maak een nieuwe tag.", + "tag_people": "Mensen taggen", "tag_updated": "Tag bijgewerkt: {tag}", "tagged_assets": "{count, plural, one {# asset} other {# assets}} getagd", "tags": "Tags", @@ -1274,11 +1297,13 @@ "unfavorite": "Verwijderen uit favorieten", "unhide_person": "Persoon zichtbaar maken", "unknown": "Onbekend", + "unknown_country": "Onbekend Land", "unknown_year": "Onbekend jaar", "unlimited": "Onbeperkt", "unlink_motion_video": "Maak bewegende video los", "unlink_oauth": "Ontkoppel OAuth", "unlinked_oauth_account": "OAuth account ontkoppeld", + "unmute_memories": "Dempen van herrinneringen opheffen", "unnamed_album": "Naamloos album", "unnamed_album_delete_confirmation": "Weet je zeker dat je dit album wilt verwijderen?", "unnamed_share": "Naamloze deellink", @@ -1332,6 +1357,7 @@ "view_all": "Bekijk alle", "view_all_users": "Bekijk alle gebruikers", "view_in_timeline": "Bekijk in tijdlijn", + "view_link": "Bekijk link", "view_links": "Links bekijken", "view_name": "Bekijken", "view_next_asset": "Bekijk volgende asset", @@ -1348,4 +1374,4 @@ "yes": "Ja", "you_dont_have_any_shared_links": "Je hebt geen gedeelde links", "zoom_image": "Inzoomen" -} +} \ No newline at end of file diff --git a/i18n/nn.json b/i18n/nn.json index e5a912e203..6de0f3401b 100644 --- a/i18n/nn.json +++ b/i18n/nn.json @@ -2,10 +2,10 @@ "about": "Om", "account": "Konto", "account_settings": "Kontoinnstillingar", - "acknowledge": "Godkjenn", + "acknowledge": "Bekreft", "action": "Handling", "actions": "Handlingar", - "active": "Aktiv", + "active": "Aktive", "activity": "Aktivitet", "activity_changed": "Aktivitet er {enabled, select, true {aktivert} other {deaktivert}}", "add": "Legg til", @@ -13,14 +13,14 @@ "add_a_location": "Legg til ein stad", "add_a_name": "Legg til eit namn", "add_a_title": "Legg til ein tittel", - "add_exclusion_pattern": "Legg til ekskluderingsmønster", + "add_exclusion_pattern": "Legg til unnlatingsmønster", "add_import_path": "Legg til sti for importering", "add_location": "Legg til stad", "add_more_users": "Legg til fleire brukarar", "add_partner": "Legg til partnar", "add_path": "Legg til sti", "add_photos": "Legg til bilete", - "add_to": "Legg til...", + "add_to": "Legg til…", "add_to_album": "Legg til album", "add_to_shared_album": "Legg til delt album", "add_url": "Legg til URL", @@ -28,29 +28,112 @@ "added_to_favorites": "Lagt til favorittar", "added_to_favorites_count": "Lagt {count, number} til favorittar", "admin": { + "add_exclusion_pattern_description": "Legg til utelatingsmønstre. Du kan bruke jokerteikna *, **, og ? for å finne filer som passar mønsteret. For å ignorere alle filer i ei mappe kalla \"Raw\", bruk \"Raw\", bruk \"**/Raw/**\". For å ignorere alle filer som sluttar på \".tif\", bruk \"**/*.tif\". For å ignorere ein absolutt sti, bruk \"/path/to/ignore/**\".", "asset_offline_description": "Denne eksterne bibliotekressursen finst ikkje lenger på disk og har blitt flytta til papirkurven. Om fila blei flytta innad i biblioteket, sjekk tidslinja di for den tilsvarande ressursen. For å gjenopprette ressursen, vennligst sørg for at filstien under er tilgjengeleg for Immich og skann biblioteket.", - "backup_settings": "Backupinnstillingar", + "authentication_settings": "Godkjenningsinnstillingar", + "authentication_settings_description": "Handsam passord, OAuth, og godkjenningsinnstillingar", + "authentication_settings_disable_all": "Er du sikker at du ynskjer å gjera alle innloggingsmetodar uverksame? Innlogging vil bli heilt uverksam.", + "authentication_settings_reenable": "For å aktivere på nytt, bruk ein Server Command.", + "background_task_job": "Bakgrunnsjobbar", + "backup_database": "Sikkerheistkopier database", + "backup_database_enable_description": "Aktiver sikkerheitskopiering av database", + "backup_keep_last_amount": "Antal sikkerheitskopiar å behalde", + "backup_settings": "Sikkerheitskopi-innstillingar", + "backup_settings_description": "Handsam innstillingar for sikkerheitskopiering av database", "check_all": "Sjekk alle", - "confirm_delete_library": "Er du sikker på at du vil slette biblioteket {library}?", + "cleared_jobs": "Rydda jobbar for: {job}", + "config_set_by_file": "Oppsettet blir sett av ei oppsettfil", + "confirm_delete_library": "Er du sikker at du vil slette biblioteket {library}?", + "confirm_delete_library_assets": "Er du sikker at du vil slette dette biblioteket? Det kjem til å slette {count, plural, one {# contained asset} other {all # contained assets}} frå Immich og kan ikkje gjerast om. Filane blir verande på disken.", + "confirm_email_below": "For å bekrefte, skriv \"{email}\" under", + "confirm_reprocess_all_faces": "Er du sikker på at du vil behandle alle ansikt på nytt? Det vil òg fjerne namngjevne personar.", + "confirm_user_password_reset": "Er du sikker at du vil tilbakestille passordet til {user}?", "create_job": "Lag jobb", + "cron_expression": "Cron uttrykk", + "cron_expression_description": "Set inn skanningsintervall med cron-formatet. For meir informasjon sjå t.d. Crontab Guru", + "cron_expression_presets": "Førehandsinstillingar for Cron-uttrykk", "disable_login": "Deaktiver innlogging", - "face_detection": "Ansiktsdeteksjon", + "duplicate_detection_job_description": "Kjør maskinlæring på filer for å oppdage liknande bilete. Krev bruk av Smart Search", + "exclusion_pattern_description": "Utelatingsmønster let deg utelate filer og mapper når du skannar biblioteket ditt. Det er nyttig om du har mapper som inneheld filer du ikkje ynskjer å importere, til dømes RAW-filer.", + "external_library_created_at": "Eksterne bibliotek (oppretta {date})", + "external_library_management": "Handsaming av eksterne bibliotek", + "face_detection": "Ansiktssøk", + "face_detection_description": "Finn ansikt i bilete ved hjelp av maskinlæring. For videoar vert berre miniatyrbilete bruka. \"Alle\" søkjer (opp att) gjennom alle bilete. \"Tilbakestill\" fjernar all gjeldande ansiktsdata. \"Manglande\" legg filer som ikkje vert behandla til i køa for ansiktssøk. Oppdaga ansikt vert lagt i køa for ansiktsattkjenning, og kopla til eksisterande eller nye personar.", + "facial_recognition_job_description": "Koplar attkjende ansikt til personar. Det skjer fyrst når anskiktssøkjet er ferdig. \"Tilbakestill\" fjernar alle koplingar til personar, og tilbakestiller ansiktsgrupper. \"Manglande\" legg ansikt som ikkje er oppkopla til i køa.", + "failed_job_command": "Kommandoen {command} feila for jobb: {job}", + "force_delete_user_warning": "ÅTVARING: Handlinga fjernar brukaren og all data. Du kan ikkje angre, og filane kan ikkje gjenopprettast.", + "forcing_refresh_library_files": "Tvingar lasting av alle filer i bibliotek", "image_format": "Format", - "image_preview_title": "Forhandsvis innstillingar", + "image_format_description": "WebP gjev mindre filstorleik enn JPEG, men er treigare å lage.", + "image_prefer_embedded_preview": "Bruk helst innebygd førehandsvisning", + "image_prefer_embedded_preview_setting_description": "Når mogleg bruk innebygd førehandsvisning av RAW bilete som inndata til biletehandsaming. For noko bilete kan det gje meir nøyaktige farger, men kvaliteten kjem an på kamera og det kan oppstå komprimeringsartefakt i bilete.", + "image_prefer_wide_gamut": "Bruk helst breitt fargespektrum", + "image_prefer_wide_gamut_setting_description": "Bruk Display P3 for miniatyrbilete. For bilete med eit breitt fargerom tek det betre vare på ljosstyrke, men på einingar med gamal nettlesarversjon kan bilete sjå usamde ut. Beheld sRGB bilete som sRGB for å unnga fargeforskuvingar.", + "image_preview_description": "Mellomstore bilete utan metadata, bruka ved vising av ei enkelt fil og til maskinlæring", + "image_preview_quality_description": "Kvalitet på førehandsvising frå 1-100. Høgare tal gjev betre kvalitet, men gjev større filstorleik og kan senkje farta på systemet. Ved låge tal kan det påverkje kvaliteten på maskinlæringa.", + "image_preview_title": "Innstillingar for førehandsvisning", "image_quality": "Kvalitet", "image_resolution": "Oppløysing", + "image_resolution_description": "Høgare oppløysing inneheld meir detalj, men tek lengre tid å kode, gjev større filstorleik, og kan senkje appresponsen.", + "image_settings": "Innstillingar for bilete", + "image_settings_description": "Handsam kvalitet og oppløysing på framstilte bilete", "image_thumbnail_description": "Lite miniatyrbilete med fjerna metadata, brukt når ein ser på grupper av bilete som hovudtidslinja", + "image_thumbnail_quality_description": "Kvalitet på miniatyrbilete frå 1-100. Høgare er betre, men gjev større filstorleik, og kan senkje appresposen.", + "image_thumbnail_title": "Innstillingar for miniatyrbilete", + "job_concurrency": "{job} samstundes utføring", "job_created": "Jobb laga", + "job_not_concurrency_safe": "Kan ikke trygt utføre jobben samstundes.", "job_settings": "Jobbinnstillingar", + "job_settings_description": "Handsam samstundes utføring av jobber", "job_status": "Jobbstatus", + "jobs_delayed": "{jobCount, plural, other {# forsinka}}", + "jobs_failed": "{jobCount, plural, other {# mislykkast}}", + "library_created": "Opprett bibliotek: {library}", "library_deleted": "Bibliotek sletta", - "library_scanning": "Periodisk skanning", + "library_import_path_description": "Angje ei mappe å importere. Mappa, inkludert undermapper, bli skanna for bilete og videoar.", + "library_scanning": "Regelbunden skanning", + "library_scanning_description": "Sett opp regelbunden skanning av biblioteket", + "library_scanning_enable_description": "Aktiver regelbunden skanning av biblioteket", "library_settings": "Eksternt Bibliotek", + "library_settings_description": "Handsam eksterne biblioteksinnstillingar", + "library_tasks_description": "Utfør bibliotekstoppgåver", + "library_watching_enable_description": "Sjekk eksterne bibliotek for forandringar", + "library_watching_settings": "Biblioteksovervåking (EKSPERIMENTELL)", + "library_watching_settings_description": "Sjekk automatisk for forandringar", + "logging_enable_description": "Aktiver loggføring", + "logging_level_description": "Når aktivert, kva loggnivå å bruke.", "logging_settings": "Logging", + "machine_learning_clip_model": "CLIP modell", + "machine_learning_clip_model_description": "Namnet på ein CLIP modell finst her. Merk at du må køyre 'Smart Søk'-jobben på nytt for alle bilete etter du har forandra modell.", "machine_learning_duplicate_detection": "Duplikatdeteksjon", + "machine_learning_duplicate_detection_enabled": "Aktiver duplikatattkjenning", + "machine_learning_duplicate_detection_enabled_description": "Om uverksam, vil identiske filer framleis bli fjerna som duplikat.", + "machine_learning_duplicate_detection_setting_description": "Bruk CLIP-innkapslingar for å finne moglege duplikat", + "machine_learning_enabled": "Aktiver maskinlæring", + "machine_learning_enabled_description": "Om uverksam blir alle ML-funksjonar uverksame uavhengig av instillingane under.", "machine_learning_facial_recognition": "Ansiktsgjenkjenning", + "machine_learning_facial_recognition_description": "Finn, kjenn att, og kople ansikt i bilete", + "machine_learning_facial_recognition_model": "Ansiktsattkjenningsmodell", + "machine_learning_facial_recognition_model_description": "Modellane er oppført i søkkjande rekkjefølge etter storleik. Større modellar er treigare og brukar meir minne, men gjev betre resultat. Om du forandrar modell lyt du køyre ansiktsattkjenning om att på alle bilete.", + "machine_learning_facial_recognition_setting": "Aktiver ansiktsattkjenning", + "machine_learning_facial_recognition_setting_description": "Om uverksam blir ikkje bilete koda for ansiktsattkjenning og dukkar ikkje opp i \"Personar\" i Utforsk-sida.", + "machine_learning_max_detection_distance": "Maksimal oppdagingsverdi", + "machine_learning_max_detection_distance_description": "Den største skilnaden mellom to bilete for å rekne dei som duplikat, frå 0.001-0.1. Større verdiar finn fleire duplikat, men kan gje falske treff.", + "machine_learning_max_recognition_distance": "Maksimal attkjenningsverdi", + "machine_learning_min_detection_score": "Minimum deteksjonsresultat", + "machine_learning_min_detection_score_description": "Minimum tillitspoeng for at eit ansikt skal bli oppdaga, på ein skala frå 0-1. Lågare verdiar vil oppdaga fleire ansikt, men kan føre til falske positive", + "machine_learning_min_recognized_faces": "Minimum gjenkjende ansikt", + "machine_learning_settings": "Innstillingar for maskinlæring", + "machine_learning_settings_description": "Administrer maskinlæringsfunksjonar og innstillingar", "machine_learning_smart_search": "Smart Søk", + "machine_learning_smart_search_enabled": "Aktiver smart søk", + "machine_learning_smart_search_enabled_description": "Hvis deaktivert, vil bilete ikkje bli enkoda for smart søk.", + "manage_concurrency": "Administrer samtidigheit", + "manage_log_settings": "Administrer logginnstillingar", "map_dark_style": "Mørk modus", + "map_enable_description": "Aktiver kartfunksjonar", + "map_gps_settings": "Kart og GPS innstillingar", + "map_gps_settings_description": "Administrer innstillingar for kart og GPS (Reversert geokoding)", "map_light_style": "Lys modus", "map_settings": "Kart", "metadata_extraction_job": "Hent ut metadata", @@ -60,18 +143,20 @@ "notification_settings": "Varselinnstillingar", "oauth_auto_launch": "Autostart", "oauth_button_text": "Tekst på knapp", - "password_settings": "Passord innlogging", + "password_enable_description": "Logg inn med e-post og passord", + "password_settings": "Passordinnlogging", "person_cleanup_job": "Personopprydding", + "refreshing_all_libraries": "Laster alle bibliotek opp att", "registration": "Administrator registrering", "registration_description": "Sidan du er den første brukaren på systemet, vil du bli utnevnt til administrator og ha ansvar for administrative oppgåver. Du vil òg opprette eventuelle nye brukarar.", "repair_all": "Reparer alle", - "repair_matched_items": "Samsvarte med {count, plural, one {# element} other {# elementer}}", - "repaired_items": "Reparerte {count, plural, one {# item} other {# items}}", + "repair_matched_items": "Samsvarte med {count, plural, one {# element} other {# element}}", + "repaired_items": "Reparerte {count, plural, one {# element} other {# element}}", "require_password_change_on_login": "Krev at brukaren endrar passord ved første pålogging", "reset_settings_to_default": "Tilbakestill innstillingar til standard", "reset_settings_to_recent_saved": "Tilbakestill innstillingane til de nyleg lagra innstillingane", "scanning_library": "Skann bibliotek", - "search_jobs": "Søk etter jobbar", + "search_jobs": "Søk etter jobbar…", "send_welcome_email": "Send velkomst-e-post", "server_external_domain_settings": "Eksternt domene", "server_external_domain_settings_description": "Domene for offentlege delingslenkjer, inkludert http(s)://", @@ -81,8 +166,26 @@ "server_settings_description": "Administrer serverinnstillingar", "server_welcome_message": "Velkomstmelding", "server_welcome_message_description": "Ei melding som synast på innloggingssida.", - "template_email_preview": "Førehandsvisning" + "system_settings": "Systeminnstillingar", + "template_email_preview": "Førehandsvisning", + "transcoding_acceleration_nvenc": "NVENC (Krev NVIDIA GPU)", + "transcoding_acceleration_qsv": "Quick Sync (Krev 7. generasjons Intel CPU eller nyare)", + "transcoding_acceleration_rkmpp": "RKMPP (Berre på Rockchip SOCer)", + "transcoding_acceleration_vaapi": "VAAPI", + "transcoding_accepted_audio_codecs": "Tillatne lydkodekar", + "transcoding_accepted_audio_codecs_description": "Vel kva for lydkodekar som ikkje må omkodast. Blir berre bruka for noko omkodingsval.", + "transcoding_accepted_containers": "Tillatne behaldarar", + "transcoding_accepted_containers_description": "Vel kva for behaldarar som ikkje må omkodast til MP4. Blir berre bruka for nokon omkodingsval.", + "transcoding_accepted_video_codecs": "Tillatne videokodekar", + "transcoding_accepted_video_codecs_description": "Vel kva for videokodekar som ikkje må omkodast. Berre bruka for nokon omkodingsval.", + "transcoding_advanced_options_description": "Innstillingar dei fleste brukarar ikkje treng forandre på", + "transcoding_audio_codec": "Lydkodek", + "transcoding_audio_codec_description": "Opus er det valet med høgast lydkvalitet, men mindre kompabilitet med gamlare einingar og programvare.", + "transcoding_bitrate_description": "Videoar med bitrate over høgste tillatte verdi, eller i eit format som ikkje er tillate", + "transcoding_codecs_learn_more": "For å lære meir om nytta begrep, sjå FFmpeg dokumentasjon for H.264 codec, HEVC codec and VP9 codec." }, + "admin_email": "Adminisrator E-post", + "admin_password": "Administratorpassord", "administration": "Administrasjon", "advanced": "Avansert", "album_with_link_access": "Lat kven som helst med lenka sjå bilete og folk i dette albumet.", @@ -92,7 +195,7 @@ "archive": "Arkiv", "asset_skipped": "Hoppa over", "asset_uploaded": "Opplasta", - "asset_uploading": "Lastar opp...", + "asset_uploading": "Lastar opp…", "back": "Tilbake", "backward": "Bakover", "camera": "Kamera", @@ -166,7 +269,7 @@ "never": "Aldri", "next": "Neste", "no": "Nei", - "no_albums_message": "Lag eit album for å organisere bileta og videoane dine.", + "no_albums_message": "Lag eit album for å organisere bileta og videoane dine", "no_archived_assets_message": "Arkiver bilder og videoar for å skjule dei frå bileta dine", "no_explore_results_message": "Last opp fleire bilete for å utforske samlinga di.", "no_libraries_message": "Lag eit eksternt bibliotek for å sjå bileta og videoane dine", @@ -232,7 +335,7 @@ "shared_from_partner": "Bilete frå {partner}", "sharing": "Deling", "show_in_timeline_setting_description": "Vis bilete og videoar frå denne brukaren i tidslinja di", - "sidebar": "Sidebar", + "sidebar": "Sidefelt", "size": "Størrelse", "slideshow": "Lysbildeframvisning", "sort_title": "Tittel", @@ -274,18 +377,40 @@ "usage": "Bruk", "user": "Brukar", "user_purchase_settings": "Kjøp", + "user_usage_detail": "Detaljar av brukars forbruk", + "user_usage_stats": "Vis kontobruksstatistikk", + "user_usage_stats_description": "Vis kontobruksstatistikk", "username": "Brukarnamn", "users": "Brukarar", "utilities": "Verktøy", "validate": "Validere", "variables": "Variablar", "version": "Versjon", + "version_announcement_closing": "Din ven, Alex", + "version_history": "Versjonshistorie", + "version_history_item": "Installert {version} den {date}", "video": "Video", + "video_hover_setting": "Spel av førehandsvisining medan du held over musepeikaren", "videos": "Videoar", + "videos_count": "{count, plural, one {# Video} other {# Videoar}}", + "view": "Vis", + "view_album": "Sjå Album", + "view_all": "Sjå alle", + "view_all_users": "Sjå alle brukarar", + "view_in_timeline": "Sjå på tidslinja", + "view_links": "Vis lenkjer", + "view_name": "Vis", + "view_next_asset": "Vis neste fil", + "view_previous_asset": "Vis forrige fil", + "view_stack": "Syn stabel", + "visibility_changed": "Synlegheit forandra for {count, plural, one {# person} other {# personar}}", "waiting": "Ventar", "warning": "Advarsel", "week": "Veke", "welcome": "Velkomen", + "welcome_to_immich": "Velkomen til Immich", "year": "År", - "yes": "Ja" + "years_ago": "{years, plural, one {# År} other {# År}} sidan", + "yes": "Ja", + "zoom_image": "Forstørr bilete" } diff --git a/i18n/pl.json b/i18n/pl.json index 49de4dc9e8..dacd054c89 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -20,7 +20,7 @@ "add_partner": "Dodaj partnera", "add_path": "Dodaj ścieżkę", "add_photos": "Dodaj zdjęcia", - "add_to": "Dodaj do...", + "add_to": "Dodaj do…", "add_to_album": "Dodaj do albumu", "add_to_shared_album": "Dodaj do udostępnionego albumu", "add_url": "Dodaj URL", @@ -39,8 +39,9 @@ "backup_database_enable_description": "Włącz kopię zapasową bazy danych", "backup_keep_last_amount": "Ile poprzednich kopii zapasowych przechowywać", "backup_settings": "Ustawienia kopii zapasowej", - "backup_settings_description": "Zarządzaj ustawieniami kopii zapasowej bazy dnaych", + "backup_settings_description": "Zarządzaj ustawieniami kopii zapasowej bazy danych", "check_all": "Zaznacz Wszystko", + "cleanup": "Czyszczenie", "cleared_jobs": "Usunięto zadania dla: {job}", "config_set_by_file": "Konfiguracja pochodzi z pliku konfiguracyjnego", "confirm_delete_library": "Czy na pewno chcesz usunąć bibliotekę {library}?", @@ -50,7 +51,7 @@ "confirm_user_password_reset": "Czy na pewno chcesz zresetować hasło użytkownika {user}?", "create_job": "Utwórz zadanie", "cron_expression": "Wyrażenie Cron", - "cron_expression_description": "Ustaw intwerwał skanowania przy pomocy formatu Cron'a. Po więcej informacji na temat formatu Cron zobacz . Crontab Guru", + "cron_expression_description": "Ustaw interwał skanowania przy pomocy formatu Cron'a. Po więcej informacji na temat formatu Cron zobacz . Crontab Guru", "cron_expression_presets": "Predefiniowane wyrażenia Cron'a", "disable_login": "Wyłącz logowanie", "duplicate_detection_job_description": "Włącz uczenie maszynowe na zasobie aby wykrywać podobne obrazy. Ta funkcja opiera się na inteligentnym wyszukiwaniu", @@ -96,7 +97,7 @@ "library_scanning_enable_description": "Włącz okresowe skanowanie bibliotek", "library_settings": "Zewnętrzne Biblioteki", "library_settings_description": "Zarządzaj ustawieniami zewnętrznych bibliotek", - "library_tasks_description": "Wykonaj zadania biblioteki", + "library_tasks_description": "Wyszukiwanie nowych lub zmienionych pozycji w zewnętrznych bibliotekach", "library_watching_enable_description": "Przejrzyj zewnętrzne biblioteki w poszukiwaniu zmienionych plików", "library_watching_settings": "Obserwowanie bibliotek (Funkcja eksperymentalna)", "library_watching_settings_description": "Automatycznie obserwuj zmienione pliki", @@ -131,7 +132,7 @@ "machine_learning_smart_search_description": "Szukaj obrazów semantycznie za pomocą CLIP", "machine_learning_smart_search_enabled": "Włącz inteligentne wyszukiwanie", "machine_learning_smart_search_enabled_description": "Jeżeli wyłączone, obrazy nie będą przygotowywane do inteligentnego wyszukiwania.", - "machine_learning_url_description": "URL serwera uczenia maszynowego. Jeżeli podano więcej niż jeden URL, do każdego serwera będzie wysłane żądanie do tej pory dopóki chociaż jeden nie odpowie, w kolejności od pierwszego do ostatniego.", + "machine_learning_url_description": "URL serwera uczenia maszynowego. Jeżeli podano więcej niż jeden URL, do każdego serwera po kolei będzie wysłane żądanie dopóki chociaż jeden nie odpowie, w kolejności od pierwszego do ostatniego. Serwery które nie odpowiedzą, zostaną tymczasowo ignorowane aż do momentu ich przejścia w stan online.", "manage_concurrency": "Zarządzaj współbieżnością zadań", "manage_log_settings": "Zarządzaj ustawieniami logów", "map_dark_style": "Styl ciemny", @@ -147,6 +148,8 @@ "map_settings": "Ustawienia Mapy", "map_settings_description": "Zarządzaj ustawieniami mapy", "map_style_description": "URL do pliku style.json z motywem mapy", + "memory_cleanup_job": "Czyszczenie pamięci", + "memory_generate_job": "Generowanie pamięci", "metadata_extraction_job": "Wyodrębnij metadane", "metadata_extraction_job_description": "Wyodrębnij informacje o metadanych z każdego zasobu, takie jak GPS, twarze i rozdzielczość", "metadata_faces_import_setting": "Włącz import twarzy", @@ -196,7 +199,7 @@ "oauth_settings_more_details": "Więcej informacji o tej funkcji znajdziesz w dokumentacji.", "oauth_signing_algorithm": "Algorytm podpisywania", "oauth_storage_label_claim": "Roszczenie dotyczące etykiety przechowywania", - "oauth_storage_label_claim_description": "Automatycznie ustaw ilość miejsca w magazynie użytkownikowi na podaną niżej wartość.", + "oauth_storage_label_claim_description": "Automatycznie ustaw etykietę przechowywania użytkownika na podaną niżej wartość.", "oauth_storage_quota_claim": "Ilość miejsca w magazynie", "oauth_storage_quota_claim_description": "Automatycznie ustaw ilość miejsca w magazynie na podaną niżej wartość.", "oauth_storage_quota_default": "Domyślna ilość miejsca w magazynie (GiB)", @@ -219,7 +222,7 @@ "reset_settings_to_default": "Przywróć ustawienia fabryczne", "reset_settings_to_recent_saved": "Przywróć ustawienia do ostatnio zapisanych", "scanning_library": "Skanowanie biblioteki", - "search_jobs": "Zadania przeszukiwania...", + "search_jobs": "Zadania przeszukiwania…", "send_welcome_email": "Wyślij powitalny e-mail", "server_external_domain_settings": "Domena zewnętrzna", "server_external_domain_settings_description": "Domena dla publicznie udostępnionych linków, wraz z http(s)://", @@ -250,7 +253,7 @@ "storage_template_user_label": "{label} to jest etykieta przechowywania użytkownika", "system_settings": "Ustawienia Systemowe", "tag_cleanup_job": "Porządkowanie etykiet", - "template_email_available_tags": "Możesz uzyć tych zmiennych w swoim szablonie: {tags}", + "template_email_available_tags": "Możesz użyć tych zmiennych w swoim szablonie: {tags}", "template_email_if_empty": "Zostaw puste, aby użyć domyślny adres e-mail.", "template_email_invite_album": "Szablon zaproszenia do albumu", "template_email_preview": "Podgląd", @@ -261,7 +264,7 @@ "template_settings": "Szablony Powiadomień", "template_settings_description": "Zarządzaj niestandardowymi szablonami powiadomień e-mail.", "theme_custom_css_settings": "Własny CSS", - "theme_custom_css_settings_description": "Właśny CSS pozwala na zmianę wyglądu aplikacji Immich.", + "theme_custom_css_settings_description": "Własny CSS pozwala na zmianę wyglądu aplikacji Immich.", "theme_settings": "Ustawienia Motywu", "theme_settings_description": "Zarządzaj wyglądem aplikacji Immich w przeglądarce", "these_files_matched_by_checksum": "Pliki te są powiązane na podstawie ich sum kontrolnych", @@ -299,7 +302,7 @@ "transcoding_max_b_frames": "Maksymalne klatki B (B-Frames)", "transcoding_max_b_frames_description": "Wyższe wartości poprawiają wydajność kompresji, ale spowalniają kodowanie. Może nie być kompatybilny z akceleracją sprzętową na starszych urządzeniach. 0 wyłącza klatki B (B-frames), natomiast -1 ustawia tę wartość automatycznie.", "transcoding_max_bitrate": "Maksymalna szybkość transmisji", - "transcoding_max_bitrate_description": "Ustawienie maksymalnej szybkości transmisji może sprawić, że rozmiary plików będą bardziej przewidywalne przy niewielkim koszcie na jakość. Przy rozdzielczości 720p typowe wartości to 2600k dla VP9 lub HEVC, lub 4500k dla H.264. Wyłączone, jeśli ustawione na 0.", + "transcoding_max_bitrate_description": "Ustawienie maksymalnej szybkości transmisji może sprawić, że rozmiary plików będą bardziej przewidywalne przy niewielkim koszcie na jakość. Przy rozdzielczości 720p typowe wartości to 2600 kbit/s dla VP9 lub HEVC, lub 4500 kbit/s dla H.264. Wyłączone, jeśli ustawione na 0.", "transcoding_max_keyframe_interval": "Maksymalny interwał klatek kluczowych", "transcoding_max_keyframe_interval_description": "Ustawia maksymalny dystans między klatkami kluczowymi. Niższe wartości przyspieszają przeszukiwanie filmów i mogą poprawić jakość w scenach z dużą ilością ruchu, kosztem gorszej efektywności kompresji. 0 ustawia tą wartość automatycznie.", "transcoding_optimal_description": "Filmy w rozdzielczości wyższej niż docelowa lub w nieakceptowanym formacie", @@ -352,7 +355,7 @@ "version_check_enabled_description": "Włącz sprawdzanie wersji", "version_check_implications": "Funkcja sprawdzania wersji opiera się na okresowej komunikacji z github.com", "version_check_settings": "Sprawdzenie Wersji", - "version_check_settings_description": "Włącz/wyłącz powiadomienie o nowej wersji", + "version_check_settings_description": "Włącz/wyłącz powiadomienia o nowej wersji", "video_conversion_job": "Transkodowanie wideo", "video_conversion_job_description": "Transkodowanie wideo w celu zapewnienia szerokiej kompatybilności z przeglądarkami i urządzeniami" }, @@ -391,6 +394,7 @@ "allow_edits": "Pozwól edytować", "allow_public_user_to_download": "Zezwól użytkownikowi publicznemu na pobieranie", "allow_public_user_to_upload": "Zezwól użytkownikowi publicznemu na przesyłanie plików", + "alt_text_qr_code": "Obrazek kodu QR", "anti_clockwise": "Przeciwnie do ruchu wskazówek zegara", "api_key": "Klucz API", "api_key_description": "Widzisz tę wartość po raz pierwszy i ostatni, więc lepiej ją skopiuj przed zamknięciem okna.", @@ -406,17 +410,17 @@ "are_these_the_same_person": "Czy to jedna i ta sama osoba?", "are_you_sure_to_do_this": "Czy aby na pewno chcesz to zrobić?", "asset_added_to_album": "Dodano do albumu", - "asset_adding_to_album": "Dodawanie do albumu...", + "asset_adding_to_album": "Dodawanie do albumu…", "asset_description_updated": "Zaktualizowano opis zasobu", "asset_filename_is_offline": "Zasób {filename} jest offline", "asset_has_unassigned_faces": "Zasób ma nieprzypisane twarze", - "asset_hashing": "Hashowanie...", + "asset_hashing": "Hashowanie…", "asset_offline": "Zasób niedostępny", "asset_offline_description": "Ten zewnętrzny zasób nie jest już dostępny na dysku. Aby uzyskać pomoc, skontaktuj się z administratorem Immich.", "asset_skipped": "Pominięto", "asset_skipped_in_trash": "W koszu", "asset_uploaded": "Przesłano", - "asset_uploading": "Przesyłanie...", + "asset_uploading": "Przesyłanie…", "assets": "Zasoby", "assets_added_count": "Dodano {count, plural, one {# zasób} few {# zasoby} many {# zasobów} other {# zasobów}}", "assets_added_to_album_count": "Dodano {count, plural, one {# zasób} few {# zasoby} many {# zasobów} other {# zasobów}} do albumu", @@ -481,6 +485,7 @@ "comments_are_disabled": "Komentarze są wyłączone", "confirm": "Potwierdź", "confirm_admin_password": "Potwierdź Hasło Administratora", + "confirm_delete_face": "Czy na pewno chcesz usunąć twarz {name} z zasobów?", "confirm_delete_shared_link": "Czy na pewno chcesz usunąć ten udostępniony link?", "confirm_keep_this_delete_others": "Wszystkie inne zasoby zostaną usunięte poza tym zasobem. Czy jesteś pewien, że chcesz kontynuować?", "confirm_password": "Potwierdź hasło", @@ -522,7 +527,7 @@ "date_of_birth_saved": "Data urodzenia zapisana pomyślnie", "date_range": "Zakres dat", "day": "Dzień", - "deduplicate_all": "Usuń Zduplikowane", + "deduplicate_all": "Usuń duplikaty", "deduplication_criteria_1": "Rozmiar obrazu w bajtach", "deduplication_criteria_2": "Ilość plików EXIF", "deduplication_info": "Stan duplikatów", @@ -533,6 +538,7 @@ "delete_album": "Usuń album", "delete_api_key_prompt": "Czy na pewno chcesz usunąć ten klucz API?", "delete_duplicates_confirmation": "Czy na pewno chcesz trwale usunąć te duplikaty?", + "delete_face": "Usuń twarz", "delete_key": "Usuń klucz", "delete_library": "Usuń bibliotekę", "delete_link": "Usuń link", @@ -548,7 +554,7 @@ "direction": "Kierunek", "disabled": "Wyłączone", "disallow_edits": "Nie pozwalaj edytować", - "discord": "Konflikt", + "discord": "Discord", "discover": "Odkryj", "dismiss_all_errors": "Odrzuć wszystkie błędy", "dismiss_error": "Odrzuć błąd", @@ -600,6 +606,7 @@ "enabled": "Włączone", "end_date": "Do dnia", "error": "Błąd", + "error_delete_face": "Wystąpił błąd podczas usuwania twarzy z zasobów", "error_loading_image": "Błąd podczas ładowania zdjęcia", "error_title": "Błąd - Coś poszło nie tak", "errors": { @@ -766,8 +773,10 @@ "go_to_folder": "Idź do folderu", "go_to_search": "Przejdź do wyszukiwania", "group_albums_by": "Grupuj albumy...", + "group_country": "Grupuj według państwa", "group_no": "Brak grupowania", "group_owner": "Grupuj według właściciela", + "group_places_by": "Grupuj miejsca według...", "group_year": "Grupuj według roku", "has_quota": "Ma limit", "hi_user": "Cześć {name} ({email})", @@ -800,6 +809,7 @@ "include_shared_albums": "Uwzględnij udostępnione albumy", "include_shared_partner_assets": "Uwzględnij udostępnione zasoby partnera", "individual_share": "Udostępniony zasób", + "individual_shares": "Indywidualne udziały", "info": "Informacje", "interval": { "day_at_onepm": "Codziennie o 13:00", @@ -822,6 +832,7 @@ "latest_version": "Ostatnia Wersja", "latitude": "Szerokość geograficzna", "leave": "Opuść", + "lens_model": "Model obiektywu", "let_others_respond": "Pozwól innym reagować", "level": "Poziom", "library": "Biblioteka", @@ -862,7 +873,7 @@ "map_settings": "Ustawienia mapy", "matches": "Powiązania", "media_type": "Typ zasobu", - "memories": "Wspomienia", + "memories": "Wspomnienia", "memories_setting_description": "Zarządzaj wspomnieniami", "memory": "Pamięć", "memory_lane_title": "Aleja Wspomnień {title}", @@ -880,6 +891,7 @@ "month": "Miesiąc", "more": "Więcej...", "moved_to_trash": "Przeniesiono do kosza", + "mute_memories": "Wycisz wspomnienia", "my_albums": "Moje albumy", "name": "Nazwa", "name_or_nickname": "Nazwa lub pseudonim", @@ -902,7 +914,7 @@ "no_duplicates_found": "Nie znaleziono duplikatów.", "no_exif_info_available": "Nie znaleziono informacji exif", "no_explore_results_message": "Prześlij więcej zdjęć, aby przeglądać swój zbiór.", - "no_favorites_message": "Dodaj ulubione aby szybko znaleść swoje najlepsze zdjęcia i filmy", + "no_favorites_message": "Dodaj ulubione aby szybko znaleźć swoje najlepsze zdjęcia i filmy", "no_libraries_message": "Stwórz bibliotekę zewnętrzną, aby przeglądać swoje zdjęcia i filmy", "no_name": "Brak Nazwy", "no_places": "Brak miejsc", @@ -984,6 +996,7 @@ "pick_a_location": "Oznacz lokalizację", "place": "Miejsce", "places": "Miejsca", + "places_count": "{count, plural, one {{count, number} Miejsce} few {{count, number} Miejsca}other {{count, number} Miejsc}}", "play": "Odtwórz", "play_memories": "Odtwórz wspomnienia", "play_motion_photo": "Odtwórz Ruchome Zdjęcie", @@ -1071,6 +1084,8 @@ "removed_from_archive": "Usunięto z archiwum", "removed_from_favorites": "Usunięto z ulubionych", "removed_from_favorites_count": "{count, plural, other {Usunięto #}} z ulubionych", + "removed_memory": "Pamięć została usunięta", + "removed_photo_from_memory": "Usunięto zdjęcie z pamięci", "removed_tagged_assets": "Usunięto etykietę z {count, plural, one {# zasobu} other {# zasobów}}", "rename": "Zmień nazwę", "repair": "Napraw", @@ -1079,6 +1094,7 @@ "repository": "Repozytorium", "require_password": "Wymagaj hasło", "require_user_to_change_password_on_first_login": "Zmuś użytkownika do zmiany hasła podczas następnego logowania", + "rescan": "Ponowne skanowanie", "reset": "Reset", "reset_password": "Resetuj hasło", "reset_people_visibility": "Zresetuj widoczność osób", @@ -1107,18 +1123,22 @@ "search": "Szukaj", "search_albums": "Przeszukaj albumy", "search_by_context": "Wyszukaj według treści", + "search_by_description": "Wyszukaj według opisu", + "search_by_description_example": "Jednodniowa wycieczka górska w Bieszczady", "search_by_filename": "Szukaj według nazwy pliku lub rozszerzenia", "search_by_filename_example": "np. IMG_1234.JPG lub PNG", "search_camera_make": "Wyszukaj markę aparatu...", "search_camera_model": "Wyszukaj model aparatu...", "search_city": "Wyszukaj miasto...", "search_country": "Wyszukaj kraj...", + "search_for": "Szukaj wśród", "search_for_existing_person": "Wyszukaj istniejącą osobę", "search_no_people": "Brak osób", "search_no_people_named": "Brak osób nazwanych \"{name}\"", "search_options": "Opcje wyszukiwania", "search_people": "Wyszukaj osoby", "search_places": "Wyszukaj miejsca", + "search_rating": "Wyszukaj według ocen...", "search_settings": "Ustawienia przeszukiwania", "search_state": "Wyszukaj stan...", "search_tags": "Wyszukaj etykiety...", @@ -1165,6 +1185,7 @@ "shared_from_partner": "Zdjęcia od {partner}", "shared_link_options": "Opcje udostępniania linku", "shared_links": "Udostępnione linki", + "shared_links_description": "Udostępnij zdjęcia oraz filmy przez link", "shared_photos_and_videos_count": "{assetCount, plural, other {# udostępnione zdjęcia i filmy.}}", "shared_with_partner": "Dzielisz się z {partner}", "sharing": "Udostępnianie", @@ -1187,6 +1208,7 @@ "show_person_options": "Pokaż opcje osoby", "show_progress_bar": "Pokaż pasek postępu", "show_search_options": "Wyświetl opcje wyszukiwania", + "show_shared_links": "Pokaż udostępniane linki", "show_slideshow_transition": "Pokaż przejście pokazu slajdów", "show_supporter_badge": "Odznaka wspierającego", "show_supporter_badge_description": "Pokaż odznakę wspierającego", @@ -1240,6 +1262,7 @@ "tag_created": "Stworzono etykietę: {tag}", "tag_feature_description": "Przeglądanie zdjęć i filmów pogrupowanych według logicznych etykiet wskazujących temat", "tag_not_found_question": "Nie możesz znaleźć etykiety? Utwórz ją tutaj", + "tag_people": "Dodaj etykiety osób", "tag_updated": "Uaktualniono etykietę: {tag}", "tagged_assets": "Przypisano etykietę {count, plural, one {# zasobowi} other {# zasobom}}", "tags": "Etykiety", @@ -1274,11 +1297,13 @@ "unfavorite": "Usuń z ulubionych", "unhide_person": "Przywróć osobę", "unknown": "Nieznany", + "unknown_country": "Nieznane państwo", "unknown_year": "Rok nieznany", "unlimited": "Nieograniczony", "unlink_motion_video": "Rozłącz ruchome wideo", "unlink_oauth": "Odłącz OAuth", "unlinked_oauth_account": "Odłączone konto OAuth", + "unmute_memories": "Włącz dźwięk wspomnień", "unnamed_album": "Nienazwany album", "unnamed_album_delete_confirmation": "Czy jesteś pewna/pewien, że chcesz usunąć te album?", "unnamed_share": "Nienazwany udział", @@ -1332,6 +1357,7 @@ "view_all": "Pokaż wszystkie", "view_all_users": "Pokaż wszystkich użytkowników", "view_in_timeline": "Pokaż na osi czasu", + "view_link": "Zobacz link", "view_links": "Pokaż łącza", "view_name": "Widok", "view_next_asset": "Wyświetl następny zasób", @@ -1348,4 +1374,4 @@ "yes": "Tak", "you_dont_have_any_shared_links": "Nie masz żadnych udostępnionych linków", "zoom_image": "Powiększ obraz" -} +} \ No newline at end of file diff --git a/i18n/pt.json b/i18n/pt.json index fa88c287fe..fe987f9564 100644 --- a/i18n/pt.json +++ b/i18n/pt.json @@ -20,7 +20,7 @@ "add_partner": "Adicionar parceiro", "add_path": "Adicionar caminho", "add_photos": "Adicionar fotos", - "add_to": "Adicionar a...", + "add_to": "Adicionar a…", "add_to_album": "Adicionar ao álbum", "add_to_shared_album": "Adicionar ao álbum partilhado", "add_url": "Adicionar URL", @@ -41,6 +41,7 @@ "backup_settings": "Definições de Cópia de Segurança", "backup_settings_description": "Gerir definições de cópia de segurança da base de dados", "check_all": "Selecionar Tudo", + "cleanup": "Limpeza", "cleared_jobs": "Eliminadas as tarefas de: {job}", "config_set_by_file": "A configuração está atualmente definida por um ficheiro de configuração", "confirm_delete_library": "Tem a certeza de que deseja eliminar a biblioteca {library} ?", @@ -96,7 +97,7 @@ "library_scanning_enable_description": "Ativar análise periódica da biblioteca", "library_settings": "Biblioteca Externa", "library_settings_description": "Gerir definições de biblioteca externa", - "library_tasks_description": "Executa tarefas de biblioteca", + "library_tasks_description": "Pesquisa bibliotecas externas em busca de itens novos e/ou alterados", "library_watching_enable_description": "Analisar bibliotecas externas por alterações de ficheiros", "library_watching_settings": "Análise de biblioteca (EXPERIMENTAL)", "library_watching_settings_description": "Analise automaticamente por ficheiros alterados", @@ -131,7 +132,7 @@ "machine_learning_smart_search_description": "Pesquise imagens semanticamente utilizando embeddings CLIP", "machine_learning_smart_search_enabled": "Ativar a Pesquisa Inteligente", "machine_learning_smart_search_enabled_description": "Se desativado, as imagens não serão codificadas para Pesquisa Inteligente.", - "machine_learning_url_description": "A URL do servidor de aprendizagem de máquina. Se for fornecido mais do que um URL, cada servidor será testado, um a um, até um deles responder com sucesso, por ordem do primeiro ao último.", + "machine_learning_url_description": "A URL do servidor de aprendizagem de máquina. Se for fornecido mais do que um URL, cada servidor será testado, um a um, até um deles responder com sucesso, por ordem do primeiro ao último. Servidores que não responderem serão temporariamente ignorados até voltarem a estar online.", "manage_concurrency": "Gerir simultaneidade", "manage_log_settings": "Gerir definições de registo", "map_dark_style": "Tema Escuro", @@ -147,6 +148,8 @@ "map_settings": "Mapa", "map_settings_description": "Gerir definições do mapa", "map_style_description": "URL para um tema de mapa style.json", + "memory_cleanup_job": "Limpeza de memórias", + "memory_generate_job": "Geração de memórias", "metadata_extraction_job": "Extrair metadados", "metadata_extraction_job_description": "Extrai informações de metadados de cada ficheiro, como GPS, rostos e resolução", "metadata_faces_import_setting": "Ativar a importação facial", @@ -219,7 +222,7 @@ "reset_settings_to_default": "Redefinir as definições para o padrão", "reset_settings_to_recent_saved": "Redefinir as definições para as guardadas mais recentemente", "scanning_library": "A analisar biblioteca", - "search_jobs": "Pesquisar tarefas...", + "search_jobs": "Pesquisar tarefas…", "send_welcome_email": "Enviar e-mail de boas-vindas", "server_external_domain_settings": "Domínio externo", "server_external_domain_settings_description": "Domínio para links públicos partilhados, incluindo http(s)://", @@ -240,7 +243,7 @@ "storage_template_hash_verification_enabled_description": "Ativa a verificação de hash, não desative esta opção a menos que tenha a certeza das implicações", "storage_template_migration": "Migração de modelo de armazenamento", "storage_template_migration_description": "Aplica o {template} atual para ficheiros previamente carregados", - "storage_template_migration_info": "As mudanças do modelo apenas se aplicarão a novos ficheiros. Para aplicar o modelo retroativamente para os ficheiros carregados anteriormente, execute o {job}.", + "storage_template_migration_info": "O modelo de armazenamento irá converter todas as extensões para letra minúscula. As mudanças do modelo apenas se aplicarão a novos ficheiros. Para aplicar o modelo retroativamente para os ficheiros carregados anteriormente, execute o {job}.", "storage_template_migration_job": "Tarefa de Migração do Modelo de Armazenamento", "storage_template_more_details": "Para mais informações sobre esta funcionalidade, dirija-se a Modelo de Armazenamento e às suas implicações", "storage_template_onboarding_description": "Quando ativada, esta funcionalidade irá organizar os ficheiros automaticamente baseando-se num modelo definido pelo utilizador. Devido a problemas de estabilidade esta funcionalidade está desativada por padrão. Para mais informações, por favor leia a documentação.", @@ -391,6 +394,7 @@ "allow_edits": "Permitir edições", "allow_public_user_to_download": "Permitir que utilizadores públicos façam transferências", "allow_public_user_to_upload": "Permitir que utilizadores públicos façam carregamentos", + "alt_text_qr_code": "Imagem do código QR", "anti_clockwise": "Sentido anti-horário", "api_key": "Chave de API", "api_key_description": "Este valor será apresentado apenas uma única vez. Por favor, certifique-se que o copiou antes de fechar a janela.", @@ -406,17 +410,17 @@ "are_these_the_same_person": "Estas pessoas são a mesma pessoa?", "are_you_sure_to_do_this": "Tem a certeza de que quer fazer isto?", "asset_added_to_album": "Adicionado ao álbum", - "asset_adding_to_album": "A adicionar ao álbum...", + "asset_adding_to_album": "A adicionar ao álbum…", "asset_description_updated": "A descrição do ficheiro foi atualizada", "asset_filename_is_offline": "O ficheiro {filename} não está disponível", "asset_has_unassigned_faces": "O ficheiro tem rostos não atribuídas", - "asset_hashing": "A criar hash...", + "asset_hashing": "A criar hash…", "asset_offline": "Ficheiro Indisponível", "asset_offline_description": "Este ficheiro externo deixou de estar disponível no disco. Contacte o seu administrador do Immich para obter ajuda.", "asset_skipped": "Ignorado", "asset_skipped_in_trash": "Na reciclagem", "asset_uploaded": "Enviado", - "asset_uploading": "A enviar...", + "asset_uploading": "A enviar…", "assets": "Ficheiros", "assets_added_count": "{count, plural, one {# ficheiro adicionado} other {# ficheiros adicionados}}", "assets_added_to_album_count": "{count, plural, one {# ficheiro adicionado} other {# ficheiros adicionados}} ao álbum", @@ -481,6 +485,7 @@ "comments_are_disabled": "Comentários estão desativados", "confirm": "Confirmar", "confirm_admin_password": "Confirmar palavra-passe de administrador", + "confirm_delete_face": "Tem a certeza de que deseja remover o rosto de {name} deste ficheiro?", "confirm_delete_shared_link": "Tem a certeza de que deseja eliminar este link partilhado?", "confirm_keep_this_delete_others": "Todos os outros ficheiros na pilha serão eliminados, exceto este ficheiro. Tem a certeza de que deseja continuar?", "confirm_password": "Confirmar a palavra-passe", @@ -526,13 +531,14 @@ "deduplication_criteria_1": "Tamanho da imagem em bytes", "deduplication_criteria_2": "Quantidade de dados EXIF", "deduplication_info": "Informações sobre remoção de duplicados", - "deduplication_info_description": "Para selecionar automaticamente itens e remover duplicados em massa, vemos o seguinte:", + "deduplication_info_description": "Para selecionar automaticamente itens e remover duplicados em massa, iremos ver o seguinte:", "default_locale": "Localização Padrão", "default_locale_description": "Formatar datas e números baseados na linguagem do seu navegador", "delete": "Eliminar", "delete_album": "Eliminar álbum", "delete_api_key_prompt": "Tem a certeza de que deseja eliminar esta chave de API?", "delete_duplicates_confirmation": "Tem a certeza de que deseja eliminar permanentemente estes itens duplicados?", + "delete_face": "Remover rosto", "delete_key": "Eliminar chave", "delete_library": "Eliminar Biblioteca", "delete_link": "Eliminar link", @@ -600,6 +606,7 @@ "enabled": "Ativado", "end_date": "Data final", "error": "Erro", + "error_delete_face": "Falha ao remover rosto do ficheiro", "error_loading_image": "Erro ao carregar a imagem", "error_title": "Erro - Algo correu mal", "errors": { @@ -766,8 +773,10 @@ "go_to_folder": "Ir para a pasta", "go_to_search": "Ir para a pesquisa", "group_albums_by": "Agrupar álbuns por...", + "group_country": "Agrupar por país", "group_no": "Sem agrupamento", "group_owner": "Agrupar por dono", + "group_places_by": "Agrupar lugares por...", "group_year": "Agrupar por ano", "has_quota": "Tem quota", "hi_user": "Olá {name} ({email})", @@ -800,6 +809,7 @@ "include_shared_albums": "Incluir álbuns partilhados", "include_shared_partner_assets": "Incluir ficheiros partilhados por parceiros", "individual_share": "Partilha individual", + "individual_shares": "Partilhas individuais", "info": "Informações", "interval": { "day_at_onepm": "Todos os dias, às 13:00", @@ -822,6 +832,7 @@ "latest_version": "Versão mais recente", "latitude": "Latitude", "leave": "Sair", + "lens_model": "Modelo de lente", "let_others_respond": "Permitir respostas", "level": "Nível", "library": "Biblioteca", @@ -880,6 +891,7 @@ "month": "Mês", "more": "Mais", "moved_to_trash": "Enviado para a reciclagem", + "mute_memories": "Silenciar Memórias", "my_albums": "Os meus álbuns", "name": "Nome", "name_or_nickname": "Nome ou alcunha", @@ -984,6 +996,7 @@ "pick_a_location": "Selecione uma localização", "place": "Lugar", "places": "Lugares", + "places_count": "{count, plural, one {{count, number} Lugar} other {{count, number} Lugares}}", "play": "Reproduzir", "play_memories": "Reproduzir memórias", "play_motion_photo": "Reproduzir foto em movimento", @@ -1071,6 +1084,8 @@ "removed_from_archive": "Removido do arquivo", "removed_from_favorites": "Removido dos favoritos", "removed_from_favorites_count": "{count, plural, other {Removidos #}} dos favoritos", + "removed_memory": "Memória removida", + "removed_photo_from_memory": "Foto removida da memória", "removed_tagged_assets": "Removida a etiqueta de {count, plural, one {# ficheiro} other {# ficheiros}}", "rename": "Mudar o nome", "repair": "Reparar", @@ -1079,6 +1094,7 @@ "repository": "Repositório", "require_password": "Proteger com palavra-passe", "require_user_to_change_password_on_first_login": "Obrigar utilizador a alterar a palavra-passe após o primeiro início de sessão", + "rescan": "Reescanear", "reset": "Redefinir", "reset_password": "Redefinir palavra-passe", "reset_people_visibility": "Redefinir pessoas ocultas", @@ -1107,18 +1123,22 @@ "search": "Pesquisar", "search_albums": "Pesquisar álbuns", "search_by_context": "Pesquisar por contexto", + "search_by_description": "Pesquisar por descrição", + "search_by_description_example": "Dia de caminhada em Leiria", "search_by_filename": "Pesquisar por nome de ficheiro ou extensão", "search_by_filename_example": "por exemplo, IMG_1234.JPG ou PNG", "search_camera_make": "Pesquisar por marca da câmara...", "search_camera_model": "Pesquisar por modelo da câmara...", "search_city": "Pesquisar cidade...", "search_country": "Pesquisar país...", + "search_for": "Pesquisar por", "search_for_existing_person": "Pesquisar por pessoas existentes", "search_no_people": "Sem pessoas", "search_no_people_named": "Nenhuma pessoa chamada \"{name}\"", "search_options": "Opções de pesquisa", "search_people": "Pesquisar pessoas", "search_places": "Pesquisar lugares", + "search_rating": "Pesquisar por classificação...", "search_settings": "Definições de pesquisa", "search_state": "Pesquisar estado/distrito...", "search_tags": "Pesquisar etiquetas...", @@ -1165,6 +1185,7 @@ "shared_from_partner": "Fotos de {partner}", "shared_link_options": "Opções de link partilhado", "shared_links": "Links partilhados", + "shared_links_description": "Partilhar fotos e videos com um link", "shared_photos_and_videos_count": "{assetCount, plural, other {# Fotos & videos partilhados.}}", "shared_with_partner": "Partilhado com {partner}", "sharing": "Partilha", @@ -1187,6 +1208,7 @@ "show_person_options": "Exibir opções da pessoa", "show_progress_bar": "Exibir barra de progresso", "show_search_options": "Exibir opções de pesquisa", + "show_shared_links": "Mostrar links partilhados", "show_slideshow_transition": "Mostrar transições no Modo de Apresentação", "show_supporter_badge": "Emblema de apoiante", "show_supporter_badge_description": "Mostrar um emblema de apoiante", @@ -1240,6 +1262,7 @@ "tag_created": "Criada a etiqueta {tag}", "tag_feature_description": "A mostrar fotos e videos agrupados por tópicos lógicos de etiquetas", "tag_not_found_question": "Não consegue encontrar a etiqueta? Crie uma nova etiqueta.", + "tag_people": "Etiquetar Pessoas", "tag_updated": "Atualizada a etiqueta: {tag}", "tagged_assets": "Etiquetado {count, plural, one {# ficheiros} other {# ficheiros}}", "tags": "Etiquetas", @@ -1274,11 +1297,13 @@ "unfavorite": "Remover favorito", "unhide_person": "Exibir pessoa", "unknown": "Desconhecido", + "unknown_country": "País desconhecido", "unknown_year": "Ano desconhecido", "unlimited": "Ilimitado", "unlink_motion_video": "Remover relação com video animado", "unlink_oauth": "Desvincular OAuth", "unlinked_oauth_account": "Conta OAuth desvinculada", + "unmute_memories": "Ativar som das memórias", "unnamed_album": "Álbum sem nome", "unnamed_album_delete_confirmation": "Tem a certeza de que pretende eliminar este álbum?", "unnamed_share": "Partilha sem nome", @@ -1332,6 +1357,7 @@ "view_all": "Ver tudo", "view_all_users": "Ver todos os utilizadores", "view_in_timeline": "Ver na linha do tempo", + "view_link": "Ver link", "view_links": "Ver links", "view_name": "Ver", "view_next_asset": "Ver próximo ficheiro", diff --git a/i18n/pt_BR.json b/i18n/pt_BR.json index 6ad0be429b..f9cf84476f 100644 --- a/i18n/pt_BR.json +++ b/i18n/pt_BR.json @@ -20,7 +20,7 @@ "add_partner": "Adicionar parceiro", "add_path": "Adicionar caminho", "add_photos": "Adicionar fotos", - "add_to": "Adicionar a...", + "add_to": "Adicionar a…", "add_to_album": "Adicionar ao álbum", "add_to_shared_album": "Adicionar ao álbum compartilhado", "add_url": "Adicionar URL", @@ -39,8 +39,9 @@ "backup_database_enable_description": "Ativar backup do banco de dados", "backup_keep_last_amount": "Quantidade de backups anteriores para manter salvo", "backup_settings": "Configurações de backup", - "backup_settings_description": "Gerenciar configurações de backup", + "backup_settings_description": "Gerenciar configurações de backup do banco de dados", "check_all": "Selecionar Tudo", + "cleanup": "Limpeza", "cleared_jobs": "Tarefas removidas de: {job}", "config_set_by_file": "A configuração está atualmente definida por um arquivo de configuração", "confirm_delete_library": "Você tem certeza que deseja excluir a biblioteca {library} ?", @@ -53,7 +54,7 @@ "cron_expression_description": "Defina o intervalo de análise no formato Cron. Para mais informações, por favor veja o Crontab Guru", "cron_expression_presets": "Sugestões de expressão Cron", "disable_login": "Desabilitar login", - "duplicate_detection_job_description": "Execute a inteligência artificial em arquivos para detectar imagens semelhantes. Depende da Pesquisa Inteligente", + "duplicate_detection_job_description": "Execute o aprendizado de máquina em arquivos para detectar imagens semelhantes. Depende da Pesquisa Inteligente", "exclusion_pattern_description": "Os padrões de exclusão permitem ignorar arquivos e pastas ao escanear sua biblioteca. Isso é útil se você tiver pastas que contenham arquivos que não deseja importar, como arquivos RAW.", "external_library_created_at": "Biblioteca externa (criada em {date})", "external_library_management": "Gerenciamento de bibliotecas externas", @@ -69,8 +70,8 @@ "image_prefer_embedded_preview_setting_description": "Use visualizações incorporadas em fotos RAW como entrada para processamento de imagem, quando disponível. Isso pode produzir cores mais precisas para algumas imagens, mas a qualidade da visualização depende da câmera e a imagem pode ter mais artefatos de compactação.", "image_prefer_wide_gamut": "Prefira ampla gama", "image_prefer_wide_gamut_setting_description": "Use o Display P3 para miniaturas. Isso preserva melhor a vibração das imagens com espaços de cores amplos, mas as imagens podem aparecer de maneira diferente em dispositivos antigos com uma versão antiga do navegador. As imagens sRGB são mantidas como sRGB para evitar mudanças de cores.", - "image_preview_description": "Imagem de tamanho médio sem os metadados, utilizado quando visualizar um único arquivo e também pela inteligência artificial", - "image_preview_quality_description": "Qualidade da pré-visualização, de 1-100. Maior é melhor, mas produz arquivos maiores e pode reduzir a velocidade do aplicativo. Definir um valor muito baixo pode afetar a qualidade da inteligência artificial.", + "image_preview_description": "Imagem de tamanho médio sem os metadados, utilizado quando visualizando um único arquivo e também pelo aprendizado de máquina", + "image_preview_quality_description": "Qualidade da pré-visualização, de 1-100. Maior é melhor, mas produz arquivos maiores e pode reduzir a velocidade do aplicativo. Definir um valor muito baixo pode afetar a qualidade do aprendizado de máquina.", "image_preview_title": "Configurações de pré-visualização", "image_quality": "Qualidade", "image_resolution": "Resolução", @@ -96,7 +97,7 @@ "library_scanning_enable_description": "Habilitar verificação periódica da biblioteca", "library_settings": "Biblioteca Externa", "library_settings_description": "Gerenciar configurações de biblioteca externa", - "library_tasks_description": "Execute tarefas de biblioteca", + "library_tasks_description": "Escanear bibliotecas externas para ativos novos ou modificados", "library_watching_enable_description": "Observe bibliotecas externas para alterações de arquivos", "library_watching_settings": "Observação de biblioteca (EXPERIMENTAL)", "library_watching_settings_description": "Observe automaticamente os arquivos alterados", @@ -108,13 +109,13 @@ "machine_learning_duplicate_detection": "Detecção de duplicidade", "machine_learning_duplicate_detection_enabled": "Habilitar detecção de duplicidade", "machine_learning_duplicate_detection_enabled_description": "Se desativado, arquivos exatamente idênticos ainda serão desduplicados.", - "machine_learning_duplicate_detection_setting_description": "Use embeddings CLIP para encontrar prováveis duplicidades", - "machine_learning_enabled": "Habilitar a inteligência artificial", - "machine_learning_enabled_description": "Se desativado, todos os recursos de ML serão desativados, independentemente das configurações abaixo.", + "machine_learning_duplicate_detection_setting_description": "Usar CLIP integrado para encontrar prováveis duplicidades", + "machine_learning_enabled": "Habilitar aprendizado de máquina", + "machine_learning_enabled_description": "Se desativado, todos os recursos de AM serão desativados, independentemente das configurações abaixo.", "machine_learning_facial_recognition": "Reconhecimento Facial", "machine_learning_facial_recognition_description": "Detectar, reconhecer e agrupar rostos em imagens", "machine_learning_facial_recognition_model": "Modelo de reconhecimento facial", - "machine_learning_facial_recognition_model_description": "Os modelos estão listados em ordem decrescente de tamanho. Modelos maiores são mais lentos e utilizam mais memória, mas produzem melhores resultados. Observe que ao alterar um modelo, você deve executar novamente a tarefa de Detecção de Rostos para todas as imagens.", + "machine_learning_facial_recognition_model_description": "Os modelos estão listados em ordem decrescente de tamanho. Modelos maiores são mais lentos e utilizam mais memória, mas produzem resultados melhores. Observe que ao alterar um modelo, você deve executar novamente a tarefa de Detecção de Rostos para todas as imagens.", "machine_learning_facial_recognition_setting": "Ativar reconhecimento facial", "machine_learning_facial_recognition_setting_description": "Se desativado, as imagens não serão codificadas para reconhecimento facial e não preencherão a seção Pessoas na página Explorar.", "machine_learning_max_detection_distance": "Distância máxima de detecção", @@ -124,14 +125,14 @@ "machine_learning_min_detection_score": "Pontuação mínima de detecção", "machine_learning_min_detection_score_description": "Pontuação mínima de confiança para um rosto ser detectado, de 0 a 1. Valores mais baixos detectam mais rostos, mas poderão resultar em falsos positivos.", "machine_learning_min_recognized_faces": "Mínimo de rostos reconhecidos", - "machine_learning_min_recognized_faces_description": "O número mínimo de rostos reconhecidos para uma pessoa ser criada na lista. Aumentar isso torna o Reconhecimento Facial mais preciso, ao custo de aumentar a chance de um rosto não ser atribuído a uma pessoa.", - "machine_learning_settings": "Configurações de inteligência artificial", - "machine_learning_settings_description": "Gerenciar recursos e configurações da inteligência artificial", + "machine_learning_min_recognized_faces_description": "O número mínimo de rostos reconhecidos para uma pessoa ser criada. Aumentar isso torna o Reconhecimento Facial mais preciso, ao custo de aumentar a chance de um rosto não ser atribuído a uma pessoa.", + "machine_learning_settings": "Configurações de aprendizado de máquina", + "machine_learning_settings_description": "Gerenciar recursos e configurações do aprendizado de máquina", "machine_learning_smart_search": "Pesquisa Inteligente", - "machine_learning_smart_search_description": "Buscar imagens semanticamente usando embeddings CLIP", + "machine_learning_smart_search_description": "Buscar imagens semanticamente usando integrações CLIP", "machine_learning_smart_search_enabled": "Habilitar a Pesquisa Inteligente", "machine_learning_smart_search_enabled_description": "Se desativado, as imagens não serão codificadas para pesquisa inteligente.", - "machine_learning_url_description": "A URL do servidor de inteligência artificial. Se mais de uma URL for configurada, o servidor irá tentar uma de cada vez até que uma delas responda com sucesso, em ordem sequencial igual a configurada.", + "machine_learning_url_description": "A URL do servidor de aprendizado de máquina. Se mais de uma URL for fornecida, elas serão tentadas, uma de cada vez e na ordem indicada, até que uma responda com sucesso. Servidores que não responderem serão ignorados temporariamente até voltarem a estar conectados.", "manage_concurrency": "Gerenciar simultaneidade", "manage_log_settings": "Gerenciar configurações de registro", "map_dark_style": "Tema Escuro", @@ -147,6 +148,8 @@ "map_settings": "Mapa", "map_settings_description": "Gerenciar configurações do mapa", "map_style_description": "URL para um tema de mapa style.json", + "memory_cleanup_job": "Limpeza de memórias", + "memory_generate_job": "Criação de memórias", "metadata_extraction_job": "Extrair metadados", "metadata_extraction_job_description": "Extraia informações dos metadados de cada arquivo, como GPS, rostos e resolução", "metadata_faces_import_setting": "Ativar a importação de rostos", @@ -219,7 +222,7 @@ "reset_settings_to_default": "Redefinir as configurações para o padrão", "reset_settings_to_recent_saved": "Redefinir as configurações para as configurações salvas recentemente", "scanning_library": "Analisando a biblioteca", - "search_jobs": "Pesquisar tarefas...", + "search_jobs": "Pesquisar tarefas…", "send_welcome_email": "Enviar e-mail de boas-vindas", "server_external_domain_settings": "Domínio externo", "server_external_domain_settings_description": "Domínio para links públicos compartilhados, incluindo http(s)://", @@ -232,7 +235,7 @@ "sidecar_job": "Metadados secundários", "sidecar_job_description": "Descubra ou sincronize metadados secundários do sistema de arquivos", "slideshow_duration_description": "Tempo em segundos para exibir cada imagem", - "smart_search_job_description": "Execute a inteligência artificial em arquivos para oferecer suporte à pesquisa inteligente", + "smart_search_job_description": "Execute aprendizado de máquina em arquivos para oferecer suporte à pesquisa inteligente", "storage_template_date_time_description": "A data e hora da criação do ativo é usado para a informações de data e hora", "storage_template_date_time_sample": "Exemplo {date}", "storage_template_enable_description": "Habilitar mecanismo de modelo de armazenamento", @@ -240,7 +243,7 @@ "storage_template_hash_verification_enabled_description": "Ativa a verificação de hash, não desative a menos que você tenha certeza das implicações", "storage_template_migration": "Migração de modelo de armazenamento", "storage_template_migration_description": "Aplique o {template} atual aos arquivos carregados anteriormente", - "storage_template_migration_info": "As mudanças no modelo serão aplicadas apenas aos novos arquivos. Para aplicar retroativamente o modelo aos arquivos carregados anteriormente, execute o {job}.", + "storage_template_migration_info": "O modelo altera todas extensões para minúsculo. As mudanças no modelo serão aplicadas apenas em novos arquivos. Para aplicar retroativamente o modelo aos arquivos carregados anteriormente, execute o {job}.", "storage_template_migration_job": "Tarefa de Migração de Modelo de Armazenamento", "storage_template_more_details": "Para mais detalhes sobre este recurso, consulte o Modelo de Armazenamento e suas implicações", "storage_template_onboarding_description": "Quando ativado, este recurso organizará automaticamente os arquivos com base em um modelo definido pelo usuário. Devido a problemas de estabilidade, o recurso está desativado por padrão. Para mais informações, consulte a documentação.", @@ -391,6 +394,7 @@ "allow_edits": "Permitir edições", "allow_public_user_to_download": "Permitir que usuários públicos baixem os arquivos", "allow_public_user_to_upload": "Permitir que usuários públicos enviem novos arquivos", + "alt_text_qr_code": "Imagem do código QR", "anti_clockwise": "Anti-horário", "api_key": "Chave de API", "api_key_description": "Este valor será mostrado apenas uma vez. Por favor, certifique-se de copiá-lo antes de fechar a janela.", @@ -406,17 +410,17 @@ "are_these_the_same_person": "Essas pessoas são a mesma pessoa?", "are_you_sure_to_do_this": "Tem certeza de que deseja fazer isso?", "asset_added_to_album": "Adicionado ao álbum", - "asset_adding_to_album": "Adicionando ao álbum...", + "asset_adding_to_album": "Adicionando ao álbum…", "asset_description_updated": "A descrição do ativo foi atualizada", "asset_filename_is_offline": "O arquivo {filename} não está disponível", "asset_has_unassigned_faces": "O arquivo tem rostos sem nomes", - "asset_hashing": "Processando...", + "asset_hashing": "Processando…", "asset_offline": "Arquivo indisponível", "asset_offline_description": "Este arquivo externo não está mais disponível. Contate seu administrador do Immich para obter ajuda.", "asset_skipped": "Ignorado", "asset_skipped_in_trash": "Na lixeira", "asset_uploaded": "Carregado", - "asset_uploading": "Carregando...", + "asset_uploading": "Carregando…", "assets": "Arquivos", "assets_added_count": "{count, plural, one {# arquivo adicionado} other {# arquivos adicionados}}", "assets_added_to_album_count": "{count, plural, one {# arquivo adicionado} other {# arquivos adicionados}} ao álbum", @@ -481,6 +485,7 @@ "comments_are_disabled": "Comentários estão desativados", "confirm": "Confirmar", "confirm_admin_password": "Confirmar senha de administrador", + "confirm_delete_face": "Tem certeza que deseja remover a face de {name} deste arquivo?", "confirm_delete_shared_link": "Tem certeza de que deseja excluir este link compartilhado?", "confirm_keep_this_delete_others": "Todos os outros arquivos da pilha serão excluídos, exceto este arquivo. Tem certeza de que deseja continuar?", "confirm_password": "Confirme a senha", @@ -533,6 +538,7 @@ "delete_album": "Excluir álbum", "delete_api_key_prompt": "Tem certeza de que deseja excluir esta chave de API?", "delete_duplicates_confirmation": "Tem certeza de que deseja excluir permanentemente estas duplicidades?", + "delete_face": "Remover face", "delete_key": "Excluir chave", "delete_library": "Excluir biblioteca", "delete_link": "Excluir link", @@ -600,6 +606,7 @@ "enabled": "Habilitado", "end_date": "Data final", "error": "Erro", + "error_delete_face": "Erro ao remover face do arquivo", "error_loading_image": "Erro ao carregar a página", "error_title": "Erro - Algo deu errado", "errors": { @@ -766,8 +773,10 @@ "go_to_folder": "Ir para a pasta", "go_to_search": "Ir para a pesquisa", "group_albums_by": "Agrupar álbuns por...", + "group_country": "Agrupar por país", "group_no": "Sem agrupamento", "group_owner": "Agrupar por dono", + "group_places_by": "Agrupar lugares por...", "group_year": "Agrupar por ano", "has_quota": "Há cota", "hi_user": "Olá {name} ({email})", @@ -800,6 +809,7 @@ "include_shared_albums": "Incluir álbuns compartilhados", "include_shared_partner_assets": "Incluir arquivos compartilhados por parceiros", "individual_share": "Compartilhamento único", + "individual_shares": "Compartilhamentos individuais", "info": "Informações", "interval": { "day_at_onepm": "Todo dia, 1pm", @@ -822,6 +832,7 @@ "latest_version": "Versão mais recente", "latitude": "Latitude", "leave": "Sair", + "lens_model": "Modelo da lente", "let_others_respond": "Permitir respostas", "level": "Nível", "library": "Biblioteca", @@ -880,6 +891,7 @@ "month": "Mês", "more": "Mais", "moved_to_trash": "Enviado para a lixeira", + "mute_memories": "Silenciar memórias", "my_albums": "Meus Álbuns", "name": "Nome", "name_or_nickname": "Nome ou apelido", @@ -984,6 +996,7 @@ "pick_a_location": "Selecione uma localização", "place": "Lugar", "places": "Lugares", + "places_count": "{count, plural, one {{count, number} Lugar} other {{count, number} Lugares}}", "play": "Reproduzir", "play_memories": "Reproduzir memórias", "play_motion_photo": "Reproduzir foto em movimento", @@ -1071,6 +1084,8 @@ "removed_from_archive": "Removido do arquivo", "removed_from_favorites": "Removido dos favoritos", "removed_from_favorites_count": "{count, plural, one {# Removido} other {# Removidos}} dos favoritos", + "removed_memory": "Memória removida", + "removed_photo_from_memory": "Foto removida da memória", "removed_tagged_assets": "Tag removida de {count, plural, one {# arquivo} other {# arquivos}}", "rename": "Renomear", "repair": "Reparar", @@ -1079,6 +1094,7 @@ "repository": "Repositório", "require_password": "Proteger com senha", "require_user_to_change_password_on_first_login": "Obrigar usuário a alterar a senha após primeiro login", + "rescan": "Reescanear", "reset": "Resetar", "reset_password": "Resetar senha", "reset_people_visibility": "Resetar pessoas ocultas", @@ -1095,7 +1111,7 @@ "role": "Função", "role_editor": "Editor", "role_viewer": "Visualizador", - "save": "Guardar", + "save": "Salvar", "saved_api_key": "Chave de API salva", "saved_profile": "Perfil Salvo", "saved_settings": "Configurações salvas", @@ -1107,18 +1123,22 @@ "search": "Pesquisar", "search_albums": "Pesquisar álbuns", "search_by_context": "Pesquisar por contexto", + "search_by_description": "Pesquisar por descrição", + "search_by_description_example": "Dia de caminhada no Ibirapuera", "search_by_filename": "Pesquisa por nome de arquivo ou extensão", "search_by_filename_example": "Por exemplo, IMG_1234.JPG ou PNG", "search_camera_make": "Pesquisar câmeras da marca...", "search_camera_model": "Pesquisar câmera do modelo...", "search_city": "Pesquisar cidade...", "search_country": "Pesquisar país...", + "search_for": "Pesquisar por", "search_for_existing_person": "Pesquisar por pessoas", "search_no_people": "Nenhuma pessoa", "search_no_people_named": "Nenhuma pessoa chamada \"{name}\"", "search_options": "Opções de pesquisa", "search_people": "Pesquisar pessoas", "search_places": "Pesquisar lugares", + "search_rating": "Pesquisar por classificação...", "search_settings": "Configurações de pesquisa", "search_state": "Pesquisar estado...", "search_tags": "Procurar tags...", @@ -1165,6 +1185,7 @@ "shared_from_partner": "Fotos de {partner}", "shared_link_options": "Opções do link compartilhado", "shared_links": "Links compartilhados", + "shared_links_description": "Compartilhar fotos e videos com um link", "shared_photos_and_videos_count": "{assetCount, plural, one {# arquivo compartilhado.} other {# arquivos compartilhados.}}", "shared_with_partner": "Compartilhado com {partner}", "sharing": "Compartilhar", @@ -1187,6 +1208,7 @@ "show_person_options": "Exibir opções da pessoa", "show_progress_bar": "Exibir barra de progresso", "show_search_options": "Exibir opções de pesquisa", + "show_shared_links": "Mostrar links compartilhados", "show_slideshow_transition": "Usar transições no modo de apresentação", "show_supporter_badge": "Insígnia de Contribuidor", "show_supporter_badge_description": "Mostrar a insígnia de contribuidor", @@ -1215,7 +1237,7 @@ "stack_select_one_photo": "Selecione uma foto principal para a pilha", "stack_selected_photos": "Empilhar fotos selecionadas", "stacked_assets_count": "{count, plural, one {# arquivo empilhado} other {# arquivos empilhados}}", - "stacktrace": "Stacktrace", + "stacktrace": "Rastreamento de pilha", "start": "Início", "start_date": "Data inicial", "state": "Estado", @@ -1240,6 +1262,7 @@ "tag_created": "Tag foi criada: {tag}", "tag_feature_description": "Visualizar fotos e videos agrupados pelo tópico da tag", "tag_not_found_question": "Não consegue encontrar a tag? Crie uma tag nova aqui.", + "tag_people": "Marcar pessoas", "tag_updated": "Tag foi atualizada: {tag}", "tagged_assets": "{count, plural, one {# arquivo marcado} other {# arquivos marcados}} com a tag", "tags": "Tags", @@ -1274,11 +1297,13 @@ "unfavorite": "Remover favorito", "unhide_person": "Exibir pessoa", "unknown": "Desconhecido", + "unknown_country": "País desconhecido", "unknown_year": "Ano desconhecido", "unlimited": "Ilimitado", "unlink_motion_video": "Remover relação com video animado", "unlink_oauth": "Desvincular OAuth", "unlinked_oauth_account": "Conta OAuth desvinculada", + "unmute_memories": "Ativar Memórias", "unnamed_album": "Álbum sem nome", "unnamed_album_delete_confirmation": "Tem certeza que deseja excluir este álbum?", "unnamed_share": "Compartilhamento sem nome", @@ -1332,6 +1357,7 @@ "view_all": "Ver tudo", "view_all_users": "Ver todos usuários", "view_in_timeline": "Ver na linha do tempo", + "view_link": "Ver link", "view_links": "Ver links", "view_name": "Ver", "view_next_asset": "Ver próximo arquivo", diff --git a/i18n/ro.json b/i18n/ro.json index d4205e331c..c88b01cd5c 100644 --- a/i18n/ro.json +++ b/i18n/ro.json @@ -299,7 +299,7 @@ "transcoding_max_b_frames": "Număr maxim de cadre B", "transcoding_max_b_frames_description": "Valorile mai mari îmbunătățesc eficiența compresiei, dar încetinesc codarea. Este posibil să nu fie compatibile cu accelerarea hardware pe dispozitivele mai vechi. 0 dezactivează cadrele B, în timp ce -1 setează această valoare automat.", "transcoding_max_bitrate": "Rata de biți maximă", - "transcoding_max_bitrate_description": "Setarea unei rate maxime de biți poate face dimensiunile fișierelor mai previzibile, cu un cost minor asupra calității. La 720p, valorile tipice sunt 2600k pentru VP9 sau HEVC, sau 4500k pentru H.264. Dezactivat dacă este setat la 0.", + "transcoding_max_bitrate_description": "Setarea unei rate maxime de biți poate face dimensiunile fișierelor mai previzibile, cu un cost minor asupra calității. La 720p, valorile tipice sunt 2600 kbit/s pentru VP9 sau HEVC, sau 4500 kbit/s pentru H.264. Dezactivat dacă este setat la 0.", "transcoding_max_keyframe_interval": "Interval maxim între cadre cheie", "transcoding_max_keyframe_interval_description": "Setează distanța maximă între cadrele cheie. Valorile mai mici reduc eficiența compresiei, dar îmbunătățesc timpii de căutare și pot îmbunătăți calitatea în scenele cu mișcare rapidă. 0 setează această valoare automat.", "transcoding_optimal_description": "Videoclipuri cu rezoluție mai mare decât cea țintă sau care nu sunt într-un format acceptat", @@ -766,8 +766,10 @@ "go_to_folder": "Accesați folderul", "go_to_search": "Spre căutare", "group_albums_by": "Grupați albume de...", + "group_country": "Grupare după țară", "group_no": "Fără grupare", "group_owner": "Grupați după proprietar", + "group_places_by": "Grupare locuri după...", "group_year": "Grupați după an", "has_quota": "Are spațiu de stocare", "hi_user": "Bună {name} ({email})", @@ -800,6 +802,7 @@ "include_shared_albums": "Include albumele partajate", "include_shared_partner_assets": "Include resursele partenerilor partajați", "individual_share": "Cota individuală", + "individual_shares": "Partajări individuale", "info": "Informație", "interval": { "day_at_onepm": "În fiecare zi la ora 13.00", @@ -822,6 +825,7 @@ "latest_version": "Ultima Versiune", "latitude": "Latitudine", "leave": "Părăsiți", + "lens_model": "Model obiectiv", "let_others_respond": "Permite altora să răspundă", "level": "Nivel", "library": "Librărie", @@ -1107,12 +1111,15 @@ "search": "Căutați", "search_albums": "Căutați albume", "search_by_context": "Căutați după context", + "search_by_description": "Căutare după descriere", + "search_by_description_example": "Zi de drumeție în Sapa", "search_by_filename": "Căutați după numele fișierului sau extensie", "search_by_filename_example": "i.e. IMG_1234.JPG sau PNG", "search_camera_make": "Se caută marca camerei...", "search_camera_model": "Se caută modelul camerei...", "search_city": "Se caută orașul...", "search_country": "Se caută țara...", + "search_for": "Căutare după", "search_for_existing_person": "Se caută o persoană existentă", "search_no_people": "Fără persoane", "search_no_people_named": "Nicio persoană numită \"{name}\"", @@ -1165,6 +1172,7 @@ "shared_from_partner": "Fotografii de la {partner}", "shared_link_options": "Opțiuni de link partajat", "shared_links": "Link-uri distribuite", + "shared_links_description": "Partajare imagini și clipuri printr-un link", "shared_photos_and_videos_count": "{assetCount, plural, other {# fotografii și videoclipuri partajate.}}", "shared_with_partner": "Partajat cu {partner}", "sharing": "Distribuire", @@ -1187,6 +1195,7 @@ "show_person_options": "Afișați opțiunile persoanelor", "show_progress_bar": "Afișați Bara de Progres", "show_search_options": "Afișați opțiunile de căutare", + "show_shared_links": "Afișare linkuri partajate", "show_slideshow_transition": "Afișați tranziția de prezentare", "show_supporter_badge": "Insigna suporterului", "show_supporter_badge_description": "Arată o insignă de suporter", @@ -1274,6 +1283,7 @@ "unfavorite": "Ștergeți din favorite", "unhide_person": "Dezvăluie persoana", "unknown": "Necunoscut", + "unknown_country": "Țară necunoscută", "unknown_year": "An Necunoscut", "unlimited": "Nelimitat", "unlink_motion_video": "Deconectați videoclipul în mișcare", @@ -1348,4 +1358,4 @@ "yes": "Da", "you_dont_have_any_shared_links": "Nu aveți linkuri partajate", "zoom_image": "Măriți Imaginea" -} +} \ No newline at end of file diff --git a/i18n/ru.json b/i18n/ru.json index 887222cb9c..b4a2eadace 100644 --- a/i18n/ru.json +++ b/i18n/ru.json @@ -20,7 +20,7 @@ "add_partner": "Добавить партнёра", "add_path": "Добавить путь", "add_photos": "Добавить фото", - "add_to": "Добавить в...", + "add_to": "Добавить в…", "add_to_album": "Добавить в альбом", "add_to_shared_album": "Добавить в общий альбом", "add_url": "Добавить URL", @@ -41,6 +41,7 @@ "backup_settings": "Настройки резервного копирования", "backup_settings_description": "Управление настройками резервного копирования базы данных", "check_all": "Проверить все", + "cleanup": "Очистка", "cleared_jobs": "Очищены задачи для: {job}", "config_set_by_file": "Настроено с помощью файла конфигурации", "confirm_delete_library": "Вы действительно хотите удалить библиотеку \"{library}\"?", @@ -96,7 +97,7 @@ "library_scanning_enable_description": "Включить периодическое сканирование библиотеки", "library_settings": "Внешняя библиотека", "library_settings_description": "Управление внешними библиотеками", - "library_tasks_description": "Выполняет задания библиотеки", + "library_tasks_description": "Сканирование внешних библиотек на наличие новых и/или изменённых объектов", "library_watching_enable_description": "Отслеживать изменения файлов внешней библиотеки", "library_watching_settings": "Слежение за библиотекой (ЭКСПЕРИМЕНТАЛЬНОЕ)", "library_watching_settings_description": "Автоматически следить за изменениями файлов", @@ -147,6 +148,8 @@ "map_settings": "Настройки карты", "map_settings_description": "Управление настройками карты", "map_style_description": "URL-адрес темы карты style.json", + "memory_cleanup_job": "Очистка воспоминаний", + "memory_generate_job": "Создание воспоминаний", "metadata_extraction_job": "Извлечение метаданных", "metadata_extraction_job_description": "Извлекает метаданные из каждого файла, такие как местоположение, лица и разрешение", "metadata_faces_import_setting": "Включить импорт лиц", @@ -219,7 +222,7 @@ "reset_settings_to_default": "Сброс настроек до значений по умолчанию", "reset_settings_to_recent_saved": "Сбросьте настройки к последним сохраненным настройкам", "scanning_library": "Сканирование библиотеки", - "search_jobs": "Поиск заданий...", + "search_jobs": "Поиск заданий…", "send_welcome_email": "Отправить приветственное письмо", "server_external_domain_settings": "Внешний домен", "server_external_domain_settings_description": "Домен для публичных ссылок, включая http(s)://", @@ -240,7 +243,7 @@ "storage_template_hash_verification_enabled_description": "Включает проверку хэша, не отключайте ее, если вы не уверены в последствиях", "storage_template_migration": "Применение шаблона хранилища", "storage_template_migration_description": "Применяет текущий {template} к ранее загруженным ресурсам", - "storage_template_migration_info": "Изменения в шаблоне будут применяться только к новым ресурсам. Чтобы применить шаблон к ранее загруженным ресурсам, запустите {job}.", + "storage_template_migration_info": "Расширения файлов всегда будут сохраняться в нижнем регистре. Изменения в шаблоне будут применяться только к новым ресурсам. Чтобы применить шаблон к ранее загруженным ресурсам, запустите {job}.", "storage_template_migration_job": "Задание миграции шаблона хранилища", "storage_template_more_details": "Для получения дополнительной информации об этой функции обратитесь к Шаблону хранилища и месту его хранения", "storage_template_onboarding_description": "При включении этой функции файлы будут автоматически организованы в соответствии с пользовательским шаблоном. Из-за проблем со стабильностью функция по умолчанию отключена. Дополнительную информацию можно найти в документации.", @@ -299,7 +302,7 @@ "transcoding_max_b_frames": "Максимально промежуточных кадров", "transcoding_max_b_frames_description": "Более высокие значения повышают эффективность сжатия, но замедляют кодирование. Может быть несовместимо с аппаратным ускорением на старых устройствах. 0 отключает B-кадры, а -1 устанавливает это значение автоматически.", "transcoding_max_bitrate": "Максимальный битрейт", - "transcoding_max_bitrate_description": "Установка максимального битрейта может сделать размер файла более предсказуемым при незначительном снижении качества. При 720p типичными значениями являются 2600k для VP9 или HEVC или 4500k для H.264. Отключено, если установлено значение 0.", + "transcoding_max_bitrate_description": "Установка максимального битрейта может сделать размер файла более предсказуемым при незначительном снижении качества. При 720p типичными значениями являются 2600 kbit/s для VP9 или HEVC или 4500 kbit/s для H.264. Отключено, если установлено значение 0.", "transcoding_max_keyframe_interval": "Максимальный интервал ключевых кадров", "transcoding_max_keyframe_interval_description": "Устанавливает максимальное расстояние между ключевыми кадрами. Более низкие значения ухудшают эффективность сжатия, но сокращают время поиска и могут улучшить качество в сценах с быстрым движением. 0 устанавливает это значение автоматически.", "transcoding_optimal_description": "Видео с разрешением выше целевого или не в принятом формате", @@ -358,7 +361,7 @@ }, "admin_email": "Электронная почта администратора", "admin_password": "Пароль администратора", - "administration": "Управление", + "administration": "Управление сервером", "advanced": "Расширенные", "age_months": "Возраст {months, plural, one {# месяц} few {# месяца} many {# месяцев} other {# месяца}}", "age_year_months": "Возраст 1 год, {months, plural, one {# месяц} few {# месяца} many {# месяцев} other {# месяца}}", @@ -391,6 +394,7 @@ "allow_edits": "Разрешить редактирование", "allow_public_user_to_download": "Разрешить скачивание публичным пользователям", "allow_public_user_to_upload": "Разрешить публичным пользователям загружать файлы", + "alt_text_qr_code": "QR-код", "anti_clockwise": "Против часовой", "api_key": "API Ключ", "api_key_description": "Это значение будет показано только один раз. Пожалуйста, убедитесь, что скопировали его перед закрытием окна.", @@ -406,17 +410,17 @@ "are_these_the_same_person": "Это один и тот же человек?", "are_you_sure_to_do_this": "Вы уверены, что хотите это сделать?", "asset_added_to_album": "Добавлено в альбом", - "asset_adding_to_album": "Добавление в альбом...", + "asset_adding_to_album": "Добавление в альбом…", "asset_description_updated": "Описание обновлено", "asset_filename_is_offline": "Объект {filename} находится в офлайн-режиме", "asset_has_unassigned_faces": "Есть не распознанные лица", - "asset_hashing": "Хеширование...", + "asset_hashing": "Хеширование…", "asset_offline": "Объект отключён", "asset_offline_description": "Этот внешний файл не найден на диске. Пожалуйста, свяжитесь с администратором Immich для получения помощи.", "asset_skipped": "Пропущено", "asset_skipped_in_trash": "В корзине", "asset_uploaded": "Загружено", - "asset_uploading": "Загрузка...", + "asset_uploading": "Загрузка…", "assets": "Объекты", "assets_added_count": "Добавлено {count, plural, one {# объект} few {# объекта} other {# объектов}}", "assets_added_to_album_count": "В альбом добавлено {count, plural, one {# объект} few {# объекта} other {# объектов}}", @@ -481,8 +485,9 @@ "comments_are_disabled": "Комментарии отключены", "confirm": "Подтвердить", "confirm_admin_password": "Подтвердите пароль Администратора", + "confirm_delete_face": "Вы точно хотите удалить лицо {name} из объекта?", "confirm_delete_shared_link": "Вы уверены, что хотите удалить эту публичную ссылку?", - "confirm_keep_this_delete_others": "Все остальные объекты в серии будут удалены, кроме этого объекта. Вы уверены, что хотите продолжить?", + "confirm_keep_this_delete_others": "Все остальные объекты в группе будут удалены, кроме этого объекта. Вы уверены, что хотите продолжить?", "confirm_password": "Подтвердите пароль", "contain": "Вместить", "context": "Контекст", @@ -533,6 +538,7 @@ "delete_album": "Удалить альбом", "delete_api_key_prompt": "Вы уверены, что хотите удалить этот ключ API?", "delete_duplicates_confirmation": "Вы уверены, что хотите навсегда удалить эти дубликаты?", + "delete_face": "Удалить лицо", "delete_key": "Удалить ключ", "delete_library": "Удалить библиотеку", "delete_link": "Удалить ссылку", @@ -600,6 +606,7 @@ "enabled": "Включено", "end_date": "Дата окончания", "error": "Ошибка", + "error_delete_face": "Ошибка при удалении лица из объекта", "error_loading_image": "Ошибка при загрузке изображения", "error_title": "Ошибка - Что-то пошло не так", "errors": { @@ -766,8 +773,10 @@ "go_to_folder": "Перейти в папку", "go_to_search": "Перейти к поиску", "group_albums_by": "Группировать альбомы по...", + "group_country": "Группировать по странам", "group_no": "Без группировки", "group_owner": "Группировать по владельцу", + "group_places_by": "Группировать места по...", "group_year": "Группировать по годам", "has_quota": "Квота", "hi_user": "Привет {name} ({email})", @@ -800,6 +809,7 @@ "include_shared_albums": "Включать общие альбомы", "include_shared_partner_assets": "Включать общие ресурсы партнера", "individual_share": "Персональный доступ", + "individual_shares": "Индивидуальный доступ", "info": "Информация", "interval": { "day_at_onepm": "Каждый день в 13:00", @@ -822,6 +832,7 @@ "latest_version": "Последняя Версия", "latitude": "Широта", "leave": "Покинуть", + "lens_model": "Модель объектива", "let_others_respond": "Позволять другим откликаться", "level": "Уровень", "library": "Библиотека", @@ -880,6 +891,7 @@ "month": "Месяц", "more": "Больше", "moved_to_trash": "Перенесено в корзину", + "mute_memories": "Отключить звук", "my_albums": "Мои альбомы", "name": "Имя", "name_or_nickname": "Имя или ник", @@ -975,6 +987,7 @@ "permanently_deleted_asset": "Удалить навсегда", "permanently_deleted_assets_count": "Безвозвратно удалено {count, plural, one {# файл} few {# файла} many {# файлов} other {# файлов}}", "person": "Человек", + "person_birthdate": "Дата рождения: {date}", "person_hidden": "{name}{hidden, select, true { (скрыт)} other {}}", "photo_shared_all_users": "Похоже, что вы поделились своими фотографиями со всеми пользователями или у вас нет пользователей, с которыми можно поделиться.", "photos": "Фото", @@ -984,6 +997,7 @@ "pick_a_location": "Выбрать местоположение", "place": "Места", "places": "Места", + "places_count": "{count, plural, one {{count, number} Место} other {{count, number} Мест}}", "play": "Воспроизвести", "play_memories": "Воспроизвести воспоминания", "play_motion_photo": "Воспроизводить движущиеся фото", @@ -1071,6 +1085,8 @@ "removed_from_archive": "Удален из архива", "removed_from_favorites": "Удалено из избранного", "removed_from_favorites_count": "{count, plural, other {Удалено #}} из избранного", + "removed_memory": "Удалить воспоминание", + "removed_photo_from_memory": "Удалить фото из воспоминания", "removed_tagged_assets": "Тег для {count, plural, one {# объекта} other {# объектов}} удален", "rename": "Переименовать", "repair": "Ремонт", @@ -1079,6 +1095,7 @@ "repository": "Репозиторий", "require_password": "Требуется пароль", "require_user_to_change_password_on_first_login": "Требовать у пользователя сменить пароль при первом входе", + "rescan": "Повторное сканирование", "reset": "Сброс", "reset_password": "Сброс пароля", "reset_people_visibility": "Восстановить видимость людей", @@ -1107,18 +1124,22 @@ "search": "Поиск", "search_albums": "Поиск альбомов", "search_by_context": "Поиск по контексту", + "search_by_description": "Поиск по описанию", + "search_by_description_example": "День пешего туризма в Сапе", "search_by_filename": "Искать по имени файла или расширению", "search_by_filename_example": "например, IMG_1234.JPG или PNG", "search_camera_make": "Поиск производителя камеры...", "search_camera_model": "Поиск модели камеры...", "search_city": "Поиск города...", "search_country": "Поиск страны...", + "search_for": "Поиск по", "search_for_existing_person": "Поиск существующего человека", "search_no_people": "Нет людей", "search_no_people_named": "Нет людей с именем \"{name}\"", "search_options": "Параметры поиска", "search_people": "Поиск людей", "search_places": "Поиск мест", + "search_rating": "Поиск по рейтингу...", "search_settings": "Настройки поиска", "search_state": "Поиск региона...", "search_tags": "Поиск по тегам...", @@ -1165,6 +1186,7 @@ "shared_from_partner": "Фото от {partner}", "shared_link_options": "Параметры публичных ссылок", "shared_links": "Публичные ссылки", + "shared_links_description": "Делитесь фотографиями и видео по ссылке", "shared_photos_and_videos_count": "{assetCount, plural, other {# фото и видео.}}", "shared_with_partner": "Совместно с {partner}", "sharing": "Общие", @@ -1187,6 +1209,7 @@ "show_person_options": "Показать опции персоны", "show_progress_bar": "Показать Индикатор Выполнения", "show_search_options": "Показать параметры поиска", + "show_shared_links": "Показать публичные ссылки", "show_slideshow_transition": "Показать слайд-шоу переход", "show_supporter_badge": "Значок поддержки", "show_supporter_badge_description": "Показать значок поддержки", @@ -1210,11 +1233,11 @@ "sort_recent": "Недавние фото", "sort_title": "Заголовок", "source": "Исходный код", - "stack": "Превратить в серию", - "stack_duplicates": "Превратить дубликаты в серию", - "stack_select_one_photo": "Выберите главную фотографию для серии", - "stack_selected_photos": "Объединить выбранные объекты в серию", - "stacked_assets_count": "{count, plural, one {# объект добавлен} few {# объекта добавлено} other {# объектов добавлено}} в серию", + "stack": "Группировать", + "stack_duplicates": "Группировать дубликаты", + "stack_select_one_photo": "Выберите главную фотографию для группы", + "stack_selected_photos": "Группировать выбранные объекты", + "stacked_assets_count": "{count, plural, one {# объект добавлен} few {# объекта добавлено} other {# объектов добавлено}} в группу", "stacktrace": "Трассировка стека", "start": "Старт", "start_date": "Дата начала", @@ -1240,6 +1263,7 @@ "tag_created": "Тег {tag} создан", "tag_feature_description": "Просмотр фотографий и видео, сгруппированных по тегам", "tag_not_found_question": "Не удается найти тег? Создайте новый тег.", + "tag_people": "Тег людей", "tag_updated": "Тег {tag} изменен", "tagged_assets": "Помечено {count, plural, one {# объект} other {# объектов}}", "tags": "Теги", @@ -1274,19 +1298,21 @@ "unfavorite": "Удалить из избранного", "unhide_person": "Показать персону", "unknown": "Неизвестно", + "unknown_country": "Неизвестная страна", "unknown_year": "Неизвестный Год", "unlimited": "Не ограничено", "unlink_motion_video": "Отсоединить движущееся видео", "unlink_oauth": "Отключить OAuth", "unlinked_oauth_account": "Отключить аккаунт OAuth", + "unmute_memories": "Включить звук", "unnamed_album": "Альбом без названия", "unnamed_album_delete_confirmation": "Вы уверены, что хотите удалить этот альбом?", "unnamed_share": "Общий доступ без названия", "unsaved_change": "Не сохраненное изменение", "unselect_all": "Снять всё", "unselect_all_duplicates": "Отменить выбор всех дубликатов", - "unstack": "Разгруппировать серию", - "unstacked_assets_count": "{count, plural, one {# объект извлечен} few {# объекта извлечено} other {# объектов извлечено}} из серии", + "unstack": "Разгруппировать", + "unstacked_assets_count": "{count, plural, one {# объект извлечен} few {# объекта извлечено} other {# объектов извлечено}} из группы", "untracked_files": "НЕОТСЛЕЖИВАЕМЫЕ ФАЙЛЫ", "untracked_files_decription": "Приложение не отслеживает эти файлы. Они могут быть результатом неудачных перемещений, прерванных загрузок или пропущены из-за ошибки", "up_next": "Следующее", @@ -1332,6 +1358,7 @@ "view_all": "Посмотреть всё", "view_all_users": "Показать всех пользователей", "view_in_timeline": "Показать на временной шкале", + "view_link": "Показать ссылку", "view_links": "Показать ссылки", "view_name": "Посмотреть", "view_next_asset": "Показать следующий объект", @@ -1348,4 +1375,4 @@ "yes": "Да", "you_dont_have_any_shared_links": "У вас нет публичных ссылок", "zoom_image": "Приблизить" -} +} \ No newline at end of file diff --git a/i18n/sk.json b/i18n/sk.json index 9af007999b..ab5c7d3f53 100644 --- a/i18n/sk.json +++ b/i18n/sk.json @@ -1,5 +1,5 @@ { - "about": "O Immich", + "about": "O aplikácii", "account": "Účet", "account_settings": "Nastavenia účtu", "acknowledge": "Rozumiem", @@ -15,12 +15,12 @@ "add_a_title": "Pridať názov", "add_exclusion_pattern": "Pridať vzor vylúčenia", "add_import_path": "Pridať cestu pre import", - "add_location": "Pridať lokáciu", + "add_location": "Pridať polohu", "add_more_users": "Pridať viac používateľov", "add_partner": "Pridať partnera", "add_path": "Pridať cestu", "add_photos": "Pridať fotografie", - "add_to": "Pridať do...", + "add_to": "Pridať do…", "add_to_album": "Pridať do albumu", "add_to_shared_album": "Pridať do zdieľaného albumu", "add_url": "Pridať URL", @@ -41,6 +41,7 @@ "backup_settings": "Nastavenia zálohovania", "backup_settings_description": "Spravovať nastavenia záloh", "check_all": "Skontrolovať všetko", + "cleanup": "Vyčistenie", "cleared_jobs": "Hotové úlohy pre: {job}", "config_set_by_file": "Konfigurácia je v súčasnosti nastavená konfiguračným súborom", "confirm_delete_library": "Naozaj chcete vymazať knižnicu {library}?", @@ -96,7 +97,7 @@ "library_scanning_enable_description": "Zapnúť pravidelné skenovanie knižnice", "library_settings": "Externá knižnica", "library_settings_description": "Spravovať nastavenia externej knižnice", - "library_tasks_description": "Vykonať úlohy knižnice", + "library_tasks_description": "Vyhľadávanie nových alebo zmenených položiek v externých knižniciach", "library_watching_enable_description": "Sledovať externé knižnice pre zmeny v súboroch", "library_watching_settings": "Sledovanie knižnice (EXPERIMENTÁLNE)", "library_watching_settings_description": "Automaticky sledovať zmenené súbory", @@ -131,7 +132,7 @@ "machine_learning_smart_search_description": "Významové vyhľadávanie v obrázkoch pomocou CLIP vzorov", "machine_learning_smart_search_enabled": "Povoliť inteligentné vyhľadávanie", "machine_learning_smart_search_enabled_description": "Ak je vypnuté, obrázky nebudú spracované pre inteligentné vyhľadávanie.", - "machine_learning_url_description": "URL adresa machine-learning servera. Ak je poskytnutých viacero URL adries, budú servery postupne testované od prvého po posledný, až kým jeden z nich úspešne odpovie.", + "machine_learning_url_description": "URL adresa servera strojového učenia. Ak je zadaných viacero adries URL, každý server bude testovaný postupne, kým jeden z nich neodpovie úspešne, v poradí od prvého po posledný. Servery, ktoré neodpovedajú, budú dočasne ignorované, kým nebudú opäť online.", "manage_concurrency": "Správa súbežnosti", "manage_log_settings": "Spravovať nastavenia logovania", "map_dark_style": "Tmavý štýl", @@ -147,6 +148,8 @@ "map_settings": "Mapa", "map_settings_description": "Spravovať nastavenia mapy", "map_style_description": "URL na motív style.json", + "memory_cleanup_job": "Vymazávanie spomienok", + "memory_generate_job": "Vytváranie spomienok", "metadata_extraction_job": "Extrahovať metadáta", "metadata_extraction_job_description": "Vytiahne metadáta z každej položky, ako napríklad GPS, tváre a rozlíšenie", "metadata_faces_import_setting": "Povoliť import tváre", @@ -219,7 +222,7 @@ "reset_settings_to_default": "Obnoviť pôvodné nastavenia", "reset_settings_to_recent_saved": "Obnoviť naposledy uložené nastavenia", "scanning_library": "Knižnica sa skenuje", - "search_jobs": "Vyhľadať úlohy...", + "search_jobs": "Vyhľadať úlohy…", "send_welcome_email": "Odoslať uvítací e-mail", "server_external_domain_settings": "Externá doména", "server_external_domain_settings_description": "Verejná doména pre zdieľané odkazy, vrátane http(s)://", @@ -299,7 +302,7 @@ "transcoding_max_b_frames": "Maximálny počet B-snímkov", "transcoding_max_b_frames_description": "Vyššie hodnoty zvyšujú účinnosť kompresie, ale spomaľujú kódovanie. Nemusí byť kompatibilný s hardvérovou akceleráciou na starších zariadeniach. Hodnota 0 zakáže B-snímky, zatiaľ čo -1 nastaví túto hodnotu automaticky.", "transcoding_max_bitrate": "Maximálna bitová rýchlosť", - "transcoding_max_bitrate_description": "Nastavenie maximálneho dátového toku môže zvýšiť predvídateľnosť veľkosti súborov za cenu menšieho zníženia kvality. Pri rozlíšení 720p sú typické hodnoty 2600k pre VP9 alebo HEVC alebo 4500k pre H.264. Zakázané, ak je nastavená hodnota 0.", + "transcoding_max_bitrate_description": "Nastavenie maximálneho dátového toku môže zvýšiť predvídateľnosť veľkosti súborov za cenu menšieho zníženia kvality. Pri rozlíšení 720p sú typické hodnoty 2600 kbit/s pre VP9 alebo HEVC alebo 4500 kbit/s pre H.264. Zakázané, ak je nastavená hodnota 0.", "transcoding_max_keyframe_interval": "Maximálny interval medzi kľúčovými snímkami", "transcoding_max_keyframe_interval_description": "Nastavuje maximálnu vzdialenosť medzi kľúčovými snímkami. Nižšie hodnoty zhoršujú účinnosť kompresie, ale zlepšujú časy vyhľadávania a môžu zlepšiť kvalitu v scénach s rýchlym pohybom. Hodnota 0 nastavuje túto hodnotu automaticky.", "transcoding_optimal_description": "Videá s vyšším ako cieľovým rozlíšením alebo videá, ktoré nie sú v prijateľnom formáte", @@ -391,6 +394,7 @@ "allow_edits": "Povoliť úpravy", "allow_public_user_to_download": "Povoľte verejnému používateľovi sťahovať", "allow_public_user_to_upload": "Umožniť verejnému používateľovi nahrávať", + "alt_text_qr_code": "Obrázok QR kódu", "anti_clockwise": "Proti smeru hodinových ručičiek", "api_key": "API Klúč", "api_key_description": "Táto hodnota sa zobrazí iba raz. Pred zatvorením okna ju určite skopírujte.", @@ -406,17 +410,17 @@ "are_these_the_same_person": "Ide o tú istú osobu?", "are_you_sure_to_do_this": "Ste si istý, že to chcete urobiť?", "asset_added_to_album": "Pridané do albumu", - "asset_adding_to_album": "Pridáva sa do albumu...", + "asset_adding_to_album": "Pridáva sa do albumu…", "asset_description_updated": "Popis média bol aktualizovaný", "asset_filename_is_offline": "Médium {filename} je offline", "asset_has_unassigned_faces": "Položka má nepriradené tváre", - "asset_hashing": "Hašovanie...", + "asset_hashing": "Hašovanie…", "asset_offline": "Médium je offline", "asset_offline_description": "Toto externý obsah sa už nenachádza na disku. Požiadajte o pomoc svojho správcu Immich.", "asset_skipped": "Preskočené", "asset_skipped_in_trash": "V koši", "asset_uploaded": "Nahrané", - "asset_uploading": "Nahráva sa...", + "asset_uploading": "Nahráva sa…", "assets": "Položky", "assets_added_count": "{count, plural, one {Pridaná # položka} few {Pridané # položky} other {Pridaných # položek}}", "assets_added_to_album_count": "Do albumu {count, plural, one {bola pridaná # položka} few {boli pridané # položky} other {bolo pridaných # položiek}}", @@ -481,6 +485,7 @@ "comments_are_disabled": "Komentáre sú vypnuté", "confirm": "Potvrdiť", "confirm_admin_password": "Potvrdiť Administrátorské Heslo", + "confirm_delete_face": "Naozaj chcete z položky odstrániť tvár osoby {name}?", "confirm_delete_shared_link": "Ste si istý, že chcete odstrániť tento zdieľaný odkaz?", "confirm_keep_this_delete_others": "Všetky ostatné položky v zásobníku budú odstránené okrem tejto položky. Naozaj chcete pokračovať?", "confirm_password": "Potvrdiť heslo", @@ -533,6 +538,7 @@ "delete_album": "Odstrániť album", "delete_api_key_prompt": "Naozaj chcete odstrániť tento API kľúč?", "delete_duplicates_confirmation": "Naozaj chcete nenávratne odstrániť tieto duplikáty?", + "delete_face": "Odstrániť tvár", "delete_key": "Odstrániť kľúč", "delete_library": "Vymazať knižnicu", "delete_link": "Odstrániť odkaz", @@ -600,6 +606,7 @@ "enabled": "Aktivovaný", "end_date": "Koncový dátum", "error": "Chyba", + "error_delete_face": "Chyba pri odstraňovaní tváre z položky", "error_loading_image": "Nepodarilo sa načítať obrázok", "error_title": "Chyba - niečo sa pokazilo", "errors": { @@ -766,8 +773,10 @@ "go_to_folder": "Prejsť do priečinka", "go_to_search": "Prejsť na vyhľadávanie", "group_albums_by": "Zoskupiť albumy podľa...", + "group_country": "Zoskupenie podľa krajiny", "group_no": "Nezoskupovať", "group_owner": "Zoskupiť podľa vlastníka", + "group_places_by": "Zoskupte miesta podľa...", "group_year": "Zoskupiť podľa roku", "has_quota": "Má kvótu", "hi_user": "Ahoj {name} ({email})", @@ -800,6 +809,7 @@ "include_shared_albums": "Zahrnúť zdieľané albumy", "include_shared_partner_assets": "Vrátane zdieľaných položiek partnera", "individual_share": "Zdieľanie jednotlivých položiek", + "individual_shares": "Individuálne zdieľanie", "info": "Informácie", "interval": { "day_at_onepm": "Každý deň v 13:00", @@ -822,6 +832,7 @@ "latest_version": "Najnovšia verzia", "latitude": "Zemepisná šírka", "leave": "Opustiť", + "lens_model": "Model objektívu", "let_others_respond": "Nechajte ostatných reagovať", "level": "Level", "library": "Knižnica", @@ -880,6 +891,7 @@ "month": "Mesiac", "more": "Viac", "moved_to_trash": "Presunuté do koša", + "mute_memories": "Vyblednutie spomienok", "my_albums": "Moje albumy", "name": "Meno", "name_or_nickname": "Meno alebo prezývka", @@ -984,6 +996,7 @@ "pick_a_location": "Vyberte miesto", "place": "Miesto", "places": "Miesta", + "places_count": "{count, plural, one {{count, number} miesto} few {{count, number} miesta} other {{count, number} miest}}", "play": "Prehrať", "play_memories": "Prehrať spomienky", "play_motion_photo": "Prehrať pohyblivú fotku", @@ -1071,6 +1084,8 @@ "removed_from_archive": "Odstránené z archívu", "removed_from_favorites": "Odstránené z obľúbených", "removed_from_favorites_count": "{count, plural, other {Odstránených #}} z obľúbených", + "removed_memory": "Odstránená pamäť", + "removed_photo_from_memory": "Fotografia odstránená z pamäte", "removed_tagged_assets": "Odstránená značka z {count, plural, one {# položky} other {# položiek}}", "rename": "Premenovať", "repair": "Opraviť", @@ -1079,6 +1094,7 @@ "repository": "Repozitár", "require_password": "Vyžadovať heslo", "require_user_to_change_password_on_first_login": "Vyžadovať zmenu hesla po prvom prihlásení", + "rescan": "Opätovné vyhľadávanie", "reset": "Resetovať", "reset_password": "Obnoviť heslo", "reset_people_visibility": "Resetovať viditeľnosť ľudí", @@ -1107,18 +1123,22 @@ "search": "Hľadať", "search_albums": "Hľadať albumy", "search_by_context": "Hľadať s kontextom", + "search_by_description": "Vyhľadávanie podľa popisu", + "search_by_description_example": "Pešia turistika v Sape", "search_by_filename": "Hľadať s názvom alebo príponou súboru", "search_by_filename_example": "napr. IMG_1234.JPG alebo PNG", "search_camera_make": "Hľadať značku fotoaparátu...", "search_camera_model": "Hľadať model fotoaparátu...", "search_city": "Hľadať mesto...", "search_country": "Hľadať krajinu...", + "search_for": "Vyhľadať", "search_for_existing_person": "Hľadať existujúcu osobu", "search_no_people": "Žiadne osoby", "search_no_people_named": "Žiadne osoby menom \"{name}\"", "search_options": "Možnosti hľadania", "search_people": "Hľadať osoby", "search_places": "Hľadať miesta", + "search_rating": "Vyhľadávanie podľa hodnotenia...", "search_settings": "Hľadať v nastaveniach", "search_state": "Hľadať štáty...", "search_tags": "Hľadať štítky...", @@ -1165,6 +1185,7 @@ "shared_from_partner": "Fotky od {partner}", "shared_link_options": "Možnosti zdieľaných odkazov", "shared_links": "Zdieľané odkazy", + "shared_links_description": "Zdieľanie fotografií a videí pomocou odkazu", "shared_photos_and_videos_count": "{assetCount, plural, other {# zdieľané fotky a videá.}}", "shared_with_partner": "Zdieľané s {partner}", "sharing": "Zdieľanie", @@ -1187,6 +1208,7 @@ "show_person_options": "Zobrazí možnosti osoby", "show_progress_bar": "Zobrazí ukazovateľ priebehu", "show_search_options": "Zobraziť možnosti vyhľadávania", + "show_shared_links": "Zobraziť zdieľané odkazy", "show_slideshow_transition": "Zobrazí prechody v prezentácii", "show_supporter_badge": "Odznak podporovateľa", "show_supporter_badge_description": "Zobraziť odznak podporovateľa", @@ -1218,7 +1240,7 @@ "stacktrace": "Výpis zásobníku", "start": "Štart", "start_date": "Začiatočný dátum", - "state": "Stav", + "state": "Štát", "status": "Stav", "stop_motion_photo": "Stopmotion fotka", "stop_photo_sharing": "Zastaviť zdieľanie vašich fotiek?", @@ -1240,6 +1262,7 @@ "tag_created": "Vytvorená značka: {tag}", "tag_feature_description": "Prehliadanie fotiek a videá zoskupených podľa tematických značiek", "tag_not_found_question": "Neviete nájsť značku? Vytvorte novú značku.", + "tag_people": "Označiť ľudí", "tag_updated": "Upravená značka: {tag}", "tagged_assets": "Značka priradená {count, plural, one {# položke} other {# položkám}}", "tags": "Štítky", @@ -1274,11 +1297,13 @@ "unfavorite": "Odznačiť ako obľúbené", "unhide_person": "Odkryť osobu", "unknown": "Neznáme", + "unknown_country": "Neznámy štát", "unknown_year": "Neznámy rok", "unlimited": "Neobmedzené", "unlink_motion_video": "Odpojiť pohyblivé video", "unlink_oauth": "Odpojiť OAuth", "unlinked_oauth_account": "Odpojiť OAuth účet", + "unmute_memories": "Zrušenie stlmenia spomienok", "unnamed_album": "Nepomenovaný album", "unnamed_album_delete_confirmation": "Ste si istý, že chcete zmazať tento album?", "unnamed_share": "Nepomenované zdieľanie", @@ -1332,6 +1357,7 @@ "view_all": "Zobraziť všetky", "view_all_users": "Zobraziť všetkých používateľov", "view_in_timeline": "Zobraziť v časovej osi", + "view_link": "Zobraziť odkaz", "view_links": "Zobraziť odkazy", "view_name": "Zobraziť", "view_next_asset": "Zobraziť nasledujúci súbor", @@ -1348,4 +1374,4 @@ "yes": "Áno", "you_dont_have_any_shared_links": "Nemáte žiadne zdielané linky", "zoom_image": "Priblížiť obrázok" -} +} \ No newline at end of file diff --git a/i18n/sl.json b/i18n/sl.json index 5073efcabc..c14c3ca3c9 100644 --- a/i18n/sl.json +++ b/i18n/sl.json @@ -20,7 +20,7 @@ "add_partner": "Dodaj partnerja", "add_path": "Dodaj pot", "add_photos": "Dodaj fotografije", - "add_to": "Dodaj v...", + "add_to": "Dodaj v…", "add_to_album": "Dodaj v album", "add_to_shared_album": "Dodaj k deljenemu albumu", "add_url": "Dodaj URL", @@ -41,6 +41,7 @@ "backup_settings": "Nastavitve varnostnega kopiranja", "backup_settings_description": "Upravljanje nastavitev varnostnih kopij", "check_all": "Označi vse", + "cleanup": "Čiščenje", "cleared_jobs": "Razčiščeno opravilo za: {job}", "config_set_by_file": "Konfiguracija je trenutno nastavljena s konfiguracijsko datoteko", "confirm_delete_library": "Ali ste prepričani, da želite izbrisati knjižnico {library}?", @@ -131,7 +132,7 @@ "machine_learning_smart_search_description": "Semantično poiščite slike z uporabo vdelav CLIP", "machine_learning_smart_search_enabled": "Omogoči pametno iskanje", "machine_learning_smart_search_enabled_description": "Če je onemogočeno, slike ne bodo kodirane za pametno iskanje.", - "machine_learning_url_description": "URL strežnika za strojno učenje. Če je na voljo več kot en URL, bo vsak strežnik poskusen posamično, dokler se eden ne odzove uspešno, v vrstnem redu od prvega do zadnjega.", + "machine_learning_url_description": "URL strežnika za strojno učenje. Če je na voljo več kot en URL, bo vsak strežnik poskusen posamično, dokler se eden ne odzove uspešno, v vrstnem redu od prvega do zadnjega. Strežniki, ki se ne odzovejo, bodo začasno prezrti, dokler se spet ne vzpostavijo.", "manage_concurrency": "Upravljanje sočasnosti", "manage_log_settings": "Upravljanje nastavitev dnevnika", "map_dark_style": "Temni način", @@ -147,6 +148,8 @@ "map_settings": "Zemljevid", "map_settings_description": "Upravljanje nastavitev zemljevida", "map_style_description": "URL do teme zemljevida style.json", + "memory_cleanup_job": "Čiščenje pomnilnika", + "memory_generate_job": "Generiranje spomina", "metadata_extraction_job": "Izvleči metapodatke", "metadata_extraction_job_description": "Izvleči informacije iz metapodatkov iz vseh virov, kot so GPS, obrazi in resolucija", "metadata_faces_import_setting": "Omogoči uvoz obraza", @@ -219,7 +222,7 @@ "reset_settings_to_default": "Ponastavi nastavitve na privzete", "reset_settings_to_recent_saved": "Ponastavite nastavitve na nedavno shranjene nastavitve", "scanning_library": "Pregledovanje knjižnice", - "search_jobs": "Iskalna opravila...", + "search_jobs": "Iskanje opravil…", "send_welcome_email": "Pošlji pozdravno e-pošto", "server_external_domain_settings": "Zunanja domena", "server_external_domain_settings_description": "Domena za javne skupne povezave, vključno s http(s)://", @@ -299,7 +302,7 @@ "transcoding_max_b_frames": "Največji B-okvirji", "transcoding_max_b_frames_description": "Višje vrednosti izboljšajo učinkovitost stiskanja, vendar upočasnijo kodiranje. Morda ni združljivo s strojnim pospeševanjem na starejših napravah. 0 onemogoči okvirje B, medtem ko -1 samodejno nastavi to vrednost.", "transcoding_max_bitrate": "Največja bitna hitrost", - "transcoding_max_bitrate_description": "Z nastavitvijo največje bitne hitrosti so lahko velikosti datotek bolj predvidljive ob manjši ceni kakovosti. Pri 720p so tipične vrednosti 2600k za VP9 ali HEVC ali 4500k za H.264. Onemogočeno, če je nastavljeno na 0.", + "transcoding_max_bitrate_description": "Z nastavitvijo največje bitne hitrosti so lahko velikosti datotek bolj predvidljive ob manjši ceni kakovosti. Pri 720p so tipične vrednosti 2600 kbit/s za VP9 ali HEVC ali 4500 kbit/s za H.264. Onemogočeno, če je nastavljeno na 0.", "transcoding_max_keyframe_interval": "Največji interval ključnih sličic", "transcoding_max_keyframe_interval_description": "Nastavi največjo razdaljo med ključnimi slikami. Nižje vrednosti poslabšajo učinkovitost stiskanja, vendar izboljšajo čas iskanja in lahko izboljšajo kakovost prizorov s hitrim gibanjem. 0 samodejno nastavi to vrednost.", "transcoding_optimal_description": "Videoposnetki, ki so višji od ciljne ločljivosti ali niso v sprejemljivem formatu", @@ -391,6 +394,7 @@ "allow_edits": "Dovoli urejanja", "allow_public_user_to_download": "Dovoli javnemu uporabniku prenos", "allow_public_user_to_upload": "Dovolite javnemu uporabniku nalaganje", + "alt_text_qr_code": "Slika QR kode", "anti_clockwise": "V nasprotni smeri urnega kazalca", "api_key": "API ključ", "api_key_description": "Ta vrednost bo prikazana samo enkrat. Ne pozabite jo kopirati, preden zaprete okno.", @@ -406,17 +410,17 @@ "are_these_the_same_person": "Ali je to ista oseba?", "are_you_sure_to_do_this": "Ste prepričani, da želite to narediti?", "asset_added_to_album": "Dodano v album", - "asset_adding_to_album": "Dodajanje v album ...", + "asset_adding_to_album": "Dodajanje v album…", "asset_description_updated": "Opis sredstva je posodobljen", "asset_filename_is_offline": "Sredstvo {filename} je brez povezave", "asset_has_unassigned_faces": "Sredstvo ima nedodeljene obraze", - "asset_hashing": "Zgoščevanje ...", + "asset_hashing": "Zgoščevanje…", "asset_offline": "Sredstvo brez povezave", "asset_offline_description": "Tega zunanjega sredstva ni več mogoče najti na disku. Za pomoč kontaktirajte Immich skrbnika.", "asset_skipped": "Preskočeno", "asset_skipped_in_trash": "V smetnjak", "asset_uploaded": "Naloženo", - "asset_uploading": "Nalaganje ...", + "asset_uploading": "Nalaganje…", "assets": "Sredstva", "assets_added_count": "Dodano{count, plural, one {# sredstvo} other {# sredstev}}", "assets_added_to_album_count": "Dodano{count, plural, one {# sredstvo} other {# sredstev}} v album", @@ -481,6 +485,7 @@ "comments_are_disabled": "Komentarji so onemogočeni", "confirm": "Potrdi", "confirm_admin_password": "Potrdite skrbniško geslo", + "confirm_delete_face": "Ali ste prepričani, da želite izbrisati obraz osebe {name} iz sredstva?", "confirm_delete_shared_link": "Ali ste prepričani, da želite izbrisati to skupno povezavo?", "confirm_keep_this_delete_others": "Vsa druga sredstva v skladu bodo izbrisana, razen tega sredstva. Ste prepričani, da želite nadaljevati?", "confirm_password": "Potrdi geslo", @@ -533,6 +538,7 @@ "delete_album": "Izbriši album", "delete_api_key_prompt": "Ali ste prepričani, da želite izbrisati ta API ključ?", "delete_duplicates_confirmation": "Ali ste prepričani, da želite trajno izbrisati te dvojnike?", + "delete_face": "Izbriši obraz", "delete_key": "Izbriši ključ", "delete_library": "Izbriši knjižnico", "delete_link": "Izbriši povezavo", @@ -600,6 +606,7 @@ "enabled": "Omogočeno", "end_date": "Končni datum", "error": "Napaka", + "error_delete_face": "Napaka pri brisanju obraza iz sredstva", "error_loading_image": "Napaka pri nalaganju slike", "error_title": "Napaka - nekaj je šlo narobe", "errors": { @@ -766,8 +773,10 @@ "go_to_folder": "Pojdi na mapo", "go_to_search": "Pojdi na iskanje", "group_albums_by": "Združi albume po ...", + "group_country": "Združi po državah", "group_no": "Brez združevanja", "group_owner": "Združi po lastniku", + "group_places_by": "Združi kraje po...", "group_year": "Združi po letih", "has_quota": "Ima kvoto", "hi_user": "Živijo {name} ({email})", @@ -800,6 +809,7 @@ "include_shared_albums": "Vključite skupne albume", "include_shared_partner_assets": "Vključite partnerjeva skupna sredstva", "individual_share": "Samostojna delitev", + "individual_shares": "Posamezne delitve", "info": "Info", "interval": { "day_at_onepm": "Vsak dan ob 13h", @@ -822,6 +832,7 @@ "latest_version": "Najnovejša različica", "latitude": "Zemljepisna širina", "leave": "Zapusti", + "lens_model": "Model leč", "let_others_respond": "Naj drugi odgovorijo", "level": "Raven", "library": "Knjižnica", @@ -880,6 +891,7 @@ "month": "Mesec", "more": "Več", "moved_to_trash": "Premaknjeno v smetnjak", + "mute_memories": "Utišaj spomine", "my_albums": "Moji albumi", "name": "Ime", "name_or_nickname": "Ime ali vzdevek", @@ -975,6 +987,7 @@ "permanently_deleted_asset": "Trajno izbrisano sredstvo", "permanently_deleted_assets_count": "Trajno izbrisano {count, plural, one {# sredstvo} two {# sredstvi} few {# sredstva} other {# sredstev}}", "person": "Oseba", + "person_birthdate": "Rojen dne {date}", "person_hidden": "{name}{hidden, select, true { (skrita)} other {}}", "photo_shared_all_users": "Videti je, da ste svoje fotografije delili z vsemi uporabniki ali pa nimate nobenega uporabnika, s katerim bi jih delili.", "photos": "Slike", @@ -984,6 +997,7 @@ "pick_a_location": "Izberi lokacijo", "place": "Lokacija", "places": "Lokacije", + "places_count": "{count, plural, one {{count, number} kraj} other {{count, number} krajev}}", "play": "Predvajaj", "play_memories": "Predvajaj spomine", "play_motion_photo": "Predvajaj premikajočo fotografijo", @@ -1071,6 +1085,8 @@ "removed_from_archive": "Odstranjeno iz arhiva", "removed_from_favorites": "Odstranjeno iz priljubljenih", "removed_from_favorites_count": "{count, plural, other {odstranen/ih #}} iz priljubljenih", + "removed_memory": "Odstranjen spomin", + "removed_photo_from_memory": "Odstranjena fotografija iz spomina", "removed_tagged_assets": "Odstranjena oznaka iz {count, plural, one {# sredstva} other {# sredstev}}", "rename": "Preimenuj", "repair": "Popravi", @@ -1079,6 +1095,7 @@ "repository": "Repozitorij", "require_password": "Zahtevaj geslo", "require_user_to_change_password_on_first_login": "Od uporabnika zahtevajte spremembo gesla ob prvi prijavi", + "rescan": "Ponovno skeniraj", "reset": "Ponastavi", "reset_password": "Ponastavi geslo", "reset_people_visibility": "Ponastavi vidnost ljudi", @@ -1107,18 +1124,22 @@ "search": "Iskanje", "search_albums": "Iskanje albumov", "search_by_context": "Iskanje po kontekstu", + "search_by_description": "Iskanje po opisu", + "search_by_description_example": "Pohodniški dan v Sapi", "search_by_filename": "Iskanje po imenu datoteke ali priponi", "search_by_filename_example": "na primer IMG_1234.JPG ali PNG", "search_camera_make": "Iskanje proizvajalca kamere...", "search_camera_model": "Išči model kamere...", "search_city": "Iskanje mesta...", "search_country": "Iskanje države...", + "search_for": "Poišči za", "search_for_existing_person": "Iskanje obstoječe osebe", "search_no_people": "Brez oseb", "search_no_people_named": "Ni oseb z imenom \"{name}\"", "search_options": "Možnosti iskanja", "search_people": "Iskanje oseb", "search_places": "Iskanje krajev", + "search_rating": "Išči po oceni ...", "search_settings": "Nastavitve iskanja", "search_state": "Iskanje dežele...", "search_tags": "Iskanje oznak...", @@ -1165,6 +1186,7 @@ "shared_from_partner": "Fotografije od {partner}", "shared_link_options": "Možnosti skupne povezave", "shared_links": "Povezave v skupni rabi", + "shared_links_description": "Deli fotografije in videoposnetke s povezavo", "shared_photos_and_videos_count": "{assetCount, plural, other {# deljenih fotografij & videoposnetkov.}}", "shared_with_partner": "V skupni rabi s/z {partner}", "sharing": "Skupna raba", @@ -1187,6 +1209,7 @@ "show_person_options": "Prikaži možnosti osebe", "show_progress_bar": "Prikaži vrstico napredka", "show_search_options": "Prikaži možnosti iskanja", + "show_shared_links": "Pokaži povezave v skupni rabi", "show_slideshow_transition": "Prikaži prehod diaprojekcije", "show_supporter_badge": "Značka podpornika", "show_supporter_badge_description": "Prikaži značko podpornika", @@ -1240,6 +1263,7 @@ "tag_created": "Ustvarjena oznaka: {tag}", "tag_feature_description": "Brskanje po fotografijah in videoposnetkih, razvrščenih po temah logičnih oznak", "tag_not_found_question": "Ne najdete oznake? Ustvarite novo oznako.", + "tag_people": "Označi osebe", "tag_updated": "Posodobljena oznaka: {tag}", "tagged_assets": "Označeno {count, plural, one {# sredstvo} two {# sredstvi} few {# sredstva} other {# sredstev}}", "tags": "Oznake", @@ -1274,11 +1298,13 @@ "unfavorite": "Odznači priljubljeno", "unhide_person": "Prikaži osebo", "unknown": "Neznano", + "unknown_country": "Neznana država", "unknown_year": "Neznano leto", "unlimited": "Neomejeno", "unlink_motion_video": "Prekini povezavo videoposnetka gibanja", "unlink_oauth": "Prekini povezavo OAuth", "unlinked_oauth_account": "Nepovezan račun OAuth", + "unmute_memories": "Vklopi zvok spominov", "unnamed_album": "Neimenovan album", "unnamed_album_delete_confirmation": "Ali ste prepričani, da želite izbrisati ta album?", "unnamed_share": "Neimenovana skupna raba", @@ -1332,6 +1358,7 @@ "view_all": "Poglej vse", "view_all_users": "Ogled vseh uporabnikov", "view_in_timeline": "Ogled na časovnici", + "view_link": "Odpri povezavo", "view_links": "Ogled povezav", "view_name": "Pogled", "view_next_asset": "Ogled naslednjega sredstva", @@ -1348,4 +1375,4 @@ "yes": "Da", "you_dont_have_any_shared_links": "Nimate nobenih skupnih povezav", "zoom_image": "Povečava slike" -} +} \ No newline at end of file diff --git a/i18n/sr_Cyrl.json b/i18n/sr_Cyrl.json index 56662c8d53..1771f9c4aa 100644 --- a/i18n/sr_Cyrl.json +++ b/i18n/sr_Cyrl.json @@ -20,7 +20,7 @@ "add_partner": "Додај партнер", "add_path": "Додај путању", "add_photos": "Додај фотографије", - "add_to": "Додај у...", + "add_to": "Додај у…", "add_to_album": "Додај у албум", "add_to_shared_album": "Додај у дељен албум", "add_url": "Додај URL", @@ -41,6 +41,7 @@ "backup_settings": "Подешавања резервне копије", "backup_settings_description": "Управљајте поставкама резервне копије базе података", "check_all": "Провери све", + "cleanup": "Чишћење", "cleared_jobs": "Очишћени послови за {job}", "config_set_by_file": "Конфигурацију тренутно поставља конфигурациони фајл", "confirm_delete_library": "Да ли стварно желите да избришете библиотеку {library} ?", @@ -96,7 +97,7 @@ "library_scanning_enable_description": "Омогућите периодично скенирање библиотеке", "library_settings": "Спољна библиотека", "library_settings_description": "Управљајте подешавањима спољне библиотеке", - "library_tasks_description": "Обављај задатке библиотеке", + "library_tasks_description": "Скенирајте спољне библиотеке у потрази за новим и/или промењеним средствима", "library_watching_enable_description": "Пратите спољне библиотеке за промене датотека", "library_watching_settings": "Надгледање библиотеке (ЕКСПЕРИМЕНТАЛНО)", "library_watching_settings_description": "Аутоматски пратите промењене датотеке", @@ -131,7 +132,7 @@ "machine_learning_smart_search_description": "Потражите слике семантички користећи уграђени ЦЛИП", "machine_learning_smart_search_enabled": "Омогућите паметну претрагу", "machine_learning_smart_search_enabled_description": "Ако је oneмогућено, слике неће бити кодиране за паметну претрагу.", - "machine_learning_url_description": "URL сервера за машинско учење. Ако је наведено више од једне URL адресе, сваки сервер ће се покушавати један по један док један не одговори успешно, редом од првог до последњег.", + "machine_learning_url_description": "URL сервера за машинско учење. Ако је наведено више од једне URL адресе, сваки сервер ће се покушавати један по један док један не одговори успешно, редом од првог до последњег. Сервери који не реагују биће привремено занемарени док се не врате на мрежу.", "manage_concurrency": "Управљање паралелношћу", "manage_log_settings": "Управљајте подешавањима евиденције", "map_dark_style": "Тамни стил", @@ -147,6 +148,8 @@ "map_settings": "Подешавање мапе", "map_settings_description": "Управљајте подешавањима мапе", "map_style_description": "УРЛ до style.json мапе тема изгледа", + "memory_cleanup_job": "Чишћење меморије", + "memory_generate_job": "Генерација меморије", "metadata_extraction_job": "Извод метаподатака", "metadata_extraction_job_description": "Извуците информације о метаподацима из сваке датотеке, као што су GPS, лица и резолуција", "metadata_faces_import_setting": "Омогући (enable) увоз лица", @@ -219,7 +222,7 @@ "reset_settings_to_default": "Ресетујте подешавања на подразумеване вредности", "reset_settings_to_recent_saved": "Ресетујте подешавања на недавно сачувана подешавања", "scanning_library": "Скенирање библиотеке", - "search_jobs": "Тражи послове...", + "search_jobs": "Тражи послове…", "send_welcome_email": "Пошаљите е-пошту добродошлице", "server_external_domain_settings": "Екстерни домаин", "server_external_domain_settings_description": "Домаин за јавне дељене везе, укључујући http(s)://", @@ -240,7 +243,7 @@ "storage_template_hash_verification_enabled_description": "Омогућава хеш верификацију, не oneмогућавајте ово осим ако нисте сигурни у последице", "storage_template_migration": "Миграција шаблона за складиштење", "storage_template_migration_description": "Примените тренутни {template} на претходно отпремљене елементе", - "storage_template_migration_info": "Промене шаблона ће се применити само на нове датотеке. Да бисте ретроактивно применили шаблон на претходно отпремљене датотеке, покрените {job}.", + "storage_template_migration_info": "Шаблон за складиштење ће претворити све екстензије у мала слова. Промене шаблона ће се применити само на нове датотеке. Да бисте ретроактивно применили шаблон на претходно отпремљене датотеке, покрените {job}.", "storage_template_migration_job": "Посао миграције складишта", "storage_template_more_details": "За више детаља о овој функцији погледајте Шаблон за складиште и његове импликације", "storage_template_onboarding_description": "Када је омогућена, ова функција ће аутоматски организовати датотеке на основу шаблона који дефинише корисник. Због проблема са стабилношћу ова функција је подразумевано искључена. За више информација погледајте документацију.", @@ -391,6 +394,7 @@ "allow_edits": "Дозволи уређење", "allow_public_user_to_download": "Дозволите јавном кориснику да преузме (download-uje)", "allow_public_user_to_upload": "Дозволи јавном кориснику да отпреми (уплоад-ује)", + "alt_text_qr_code": "Слика QR кода", "anti_clockwise": "У смеру супротном од казаљке на сату", "api_key": "АПИ кључ (key)", "api_key_description": "Ова вредност ће бити приказана само једном. Обавезно копирајте пре него што затворите прозор.", @@ -406,17 +410,17 @@ "are_these_the_same_person": "Да ли су ово иста особа?", "are_you_sure_to_do_this": "Јесте ли сигурни да желите ово да урадите?", "asset_added_to_album": "Додато у албум", - "asset_adding_to_album": "Додаје се у албум...", + "asset_adding_to_album": "Додаје се у албум…", "asset_description_updated": "Опис датотеке је ажуриран", "asset_filename_is_offline": "Датотека {filename} је ван мреже (offline)", "asset_has_unassigned_faces": "Датотека има недодељена лица", - "asset_hashing": "Хеширање...", + "asset_hashing": "Хеширање…", "asset_offline": "Датотека одсутна (offline)", "asset_offline_description": "Ова вањска датотека се више не налази на диску. Молимо контактирајте свог Имич администратора за помоћ.", "asset_skipped": "Прескочено", "asset_skipped_in_trash": "У отпад", "asset_uploaded": "Отпремљено (Уплоадед)", - "asset_uploading": "Отпремање...", + "asset_uploading": "Отпремање…", "assets": "Записи", "assets_added_count": "Додато {count, plural, one {# датотека} other {# датотека}}", "assets_added_to_album_count": "Додато је {count, plural, one {# датотека} other {# датотека}} у албум", @@ -481,6 +485,7 @@ "comments_are_disabled": "Коментари су oneмогућени", "confirm": "Потврдите", "confirm_admin_password": "Потврди Административну Лозинку", + "confirm_delete_face": "Да ли сте сигурни да желите да избришете особу {name} из дела?", "confirm_delete_shared_link": "Да ли сте сигурни да желите да избришете овај дељени link?", "confirm_keep_this_delete_others": "Свe осталe датотекe у групи ће бити избрисанe осим овe датотекe. Да ли сте сигурни да желите да наставите?", "confirm_password": "Поново унеси шифру", @@ -533,6 +538,7 @@ "delete_album": "Обриши албум", "delete_api_key_prompt": "Да ли сте сигурни да желите да избришете овај АПИ кључ (key)?", "delete_duplicates_confirmation": "Да ли сте сигурни да желите да трајно избришете ове дупликате?", + "delete_face": "Избриши особу", "delete_key": "Избриши кључ", "delete_library": "Обриши библиотеку", "delete_link": "Обриши везу", @@ -600,6 +606,7 @@ "enabled": "Омогућено (enabled)", "end_date": "Крајњи датум", "error": "Грешка", + "error_delete_face": "Грешка при брисању особе из дела", "error_loading_image": "Грешка при учитавању слике", "error_title": "Грешка – Нешто је пошло наопако", "errors": { @@ -766,8 +773,10 @@ "go_to_folder": "Иди у фасциклу", "go_to_search": "Иди на претрагу", "group_albums_by": "Групни албуми по...", + "group_country": "Група по држава", "group_no": "Без груписања", "group_owner": "Групирајте по власнику", + "group_places_by": "Групирајте места по...", "group_year": "Групирајте по години", "has_quota": "Има квоту", "hi_user": "Здраво {name} ({email})", @@ -800,6 +809,7 @@ "include_shared_albums": "Обухвати дељене албуме", "include_shared_partner_assets": "Обухвати заједничке датотеке партнера", "individual_share": "Индивидуални удео", + "individual_shares": "Појединачне акције", "info": "Информација", "interval": { "day_at_onepm": "Сваки дан у 1пм", @@ -822,6 +832,7 @@ "latest_version": "Најновија верзија", "latitude": "Географска ширина", "leave": "Напусти", + "lens_model": "Модел сочива", "let_others_respond": "Дозволи да други коментаришу", "level": "Ниво", "library": "Библиотека", @@ -880,6 +891,7 @@ "month": "Месец", "more": "Више", "moved_to_trash": "Премештено у смеће", + "mute_memories": "Пригуши сећања", "my_albums": "Моји албуми", "name": "Име", "name_or_nickname": "Име или надимак", @@ -984,6 +996,7 @@ "pick_a_location": "Одабери локацију", "place": "Место", "places": "Места", + "places_count": "{count, plural, one {{count, number} Место} other {{count, number} Местa}}", "play": "Покрени", "play_memories": "Покрени сећања", "play_motion_photo": "Покрени покретну фотографију", @@ -1071,6 +1084,8 @@ "removed_from_archive": "Уклоњено из архиве", "removed_from_favorites": "Уклоњено из омиљених (фаворитес)", "removed_from_favorites_count": "{count, plural, other {Уклоњено #}} из омиљених", + "removed_memory": "Уклоњена меморија", + "removed_photo_from_memory": "Слика је уклоњена из меморије", "removed_tagged_assets": "Уклоњена ознака (tag) из {count, plural, one {# датотеке} other {# датотека}}", "rename": "Преименуј", "repair": "Поправи", @@ -1079,6 +1094,7 @@ "repository": "Репозиторијум (Repository)", "require_password": "Потребна лозинка", "require_user_to_change_password_on_first_login": "Захтевати од корисника да промени лозинку при првом пријављивању", + "rescan": "Поново скенирај", "reset": "Ресетовати", "reset_password": "Ресетовати лозинку", "reset_people_visibility": "Ресетујте видљивост особа", @@ -1107,18 +1123,22 @@ "search": "Претрага", "search_albums": "Претражи албуме", "search_by_context": "Претражујте по контексту", + "search_by_description": "Тражи по опису", + "search_by_description_example": "Дан пешачења у Сапи", "search_by_filename": "Претражите по имену датотеке или екстензији", "search_by_filename_example": "нпр. IMG_1234.JPG или PNG", "search_camera_make": "Претрага произвођача камере...", "search_camera_model": "Претражи модел камере...", "search_city": "Претражи град...", "search_country": "Тражи земљу...", + "search_for": "Тражи", "search_for_existing_person": "Потражите постојећу особу", "search_no_people": "Без особа", "search_no_people_named": "Нема особа са именом „{name}“", "search_options": "Опције претраге", "search_people": "Претражи особе", "search_places": "Претражи места", + "search_rating": "Претрага по оцени...", "search_settings": "Претрага подешавања", "search_state": "Тражи регион...", "search_tags": "Претражи ознаке (tags)...", @@ -1165,6 +1185,7 @@ "shared_from_partner": "Слике од {partner}", "shared_link_options": "Опције дељене везе", "shared_links": "Дељене везе", + "shared_links_description": "Делите фотографије и видео записе помоћу линка", "shared_photos_and_videos_count": "{assetCount, plural, other {# дељене фотографије и видео записе.}}", "shared_with_partner": "Дели се са {partner}", "sharing": "Дељење", @@ -1187,6 +1208,7 @@ "show_person_options": "Прикажи опције особе", "show_progress_bar": "Прикажи траку напретка", "show_search_options": "Прикажи опције претраге", + "show_shared_links": "Прикажи дељене везе", "show_slideshow_transition": "Прикажи прелаз пројекције слајдова", "show_supporter_badge": "Значка подршке", "show_supporter_badge_description": "Покажите значку подршке", @@ -1240,6 +1262,7 @@ "tag_created": "Направљена ознака (tag): {tag}", "tag_feature_description": "Прегледавање фотографија и видео снимака груписаних по логичним темама ознака", "tag_not_found_question": "Не можете да пронађете ознаку (tag)? Направите нову ознаку", + "tag_people": "Означите људе", "tag_updated": "Ажурирана ознака (tag): {tag}", "tagged_assets": "Означено (tagged) {count, plural, one {# датотека} other {# датотеке}}", "tags": "Ознаке (tags)", @@ -1274,11 +1297,13 @@ "unfavorite": "Избаци из омиљених (унфаворите)", "unhide_person": "Откриј особу", "unknown": "Непознат", + "unknown_country": "Непозната земља", "unknown_year": "Непозната Година", "unlimited": "Неограничено", "unlink_motion_video": "Прекините везу са видео снимком", "unlink_oauth": "Прекини везу са Oauth-om", "unlinked_oauth_account": "Опозвана веза OAuth налога", + "unmute_memories": "Укључи успомене", "unnamed_album": "Неименовани албум", "unnamed_album_delete_confirmation": "Да ли сте сигурни да желите да избришете овај албум?", "unnamed_share": "Неименовано делење", @@ -1332,6 +1357,7 @@ "view_all": "Прикажи Све", "view_all_users": "Прикажи све кориснике", "view_in_timeline": "Прикажи у временској линији", + "view_link": "Погледај везу", "view_links": "Прикажи везе", "view_name": "Погледати", "view_next_asset": "Погледајте следећу датотеку", diff --git a/i18n/sr_Latn.json b/i18n/sr_Latn.json index b5993cc9a6..f6743c012b 100644 --- a/i18n/sr_Latn.json +++ b/i18n/sr_Latn.json @@ -20,7 +20,7 @@ "add_partner": "Dodaj partner", "add_path": "Dodaj putanju", "add_photos": "Dodaj fotografije", - "add_to": "Dodaj u...", + "add_to": "Dodaj u…", "add_to_album": "Dodaj u album", "add_to_shared_album": "Dodaj u deljen album", "add_url": "Dodaj URL", @@ -41,6 +41,7 @@ "backup_settings": "Podešavanja rezervne kopije", "backup_settings_description": "Upravljajte postavkama rezervne kopije baze podataka", "check_all": "Proveri sve", + "cleanup": "Čišćenje", "cleared_jobs": "Očišćeni poslovi za: {job}", "config_set_by_file": "Konfiguraciju trenutno postavlja konfiguracioni fajl", "confirm_delete_library": "Da li stvarno želite da izbrišete biblioteku {library} ?", @@ -96,7 +97,7 @@ "library_scanning_enable_description": "Omogućite periodično skeniranje biblioteke", "library_settings": "Spoljna biblioteka", "library_settings_description": "Upravljajte podešavanjima spoljne biblioteke", - "library_tasks_description": "Obavljaj zadatke biblioteke", + "library_tasks_description": "Skenirajte spoljne biblioteke u potrazi za novim i/ili promenjenim sredstvima", "library_watching_enable_description": "Pratite spoljne biblioteke za promene datoteka", "library_watching_settings": "Nadgledanje biblioteke (EKSPERIMENTALNO)", "library_watching_settings_description": "Automatski pratite promenjene datoteke", @@ -131,7 +132,7 @@ "machine_learning_smart_search_description": "Potražite slike semantički koristeći ugrađeni CLIP", "machine_learning_smart_search_enabled": "Omogućite pametnu pretragu", "machine_learning_smart_search_enabled_description": "Ako je onemogućeno, slike neće biti kodirane za pametnu pretragu.", - "machine_learning_url_description": "URL servera za mašinsko učenje. Ako je obezbeđeno više URL-ova, svaki server će biti pokušan redom, jedan po jedan, dok jedan ne odgovori uspešno, po redosledu od prvog do poslednjeg.", + "machine_learning_url_description": "URL servera za mašinsko učenje. Ako je obezbeđeno više URL-ova, svaki server će biti pokušan redom, jedan po jedan, dok jedan ne odgovori uspešno, po redosledu od prvog do poslednjeg. Serveri koji ne reaguju biće privremeno zanemareni dok se ne vrate na mrežu.", "manage_concurrency": "Upravljanje paralelnošću", "manage_log_settings": "Upravljajte podešavanjima evidencije", "map_dark_style": "Tamni stil", @@ -147,6 +148,8 @@ "map_settings": "Podešavanje mape", "map_settings_description": "Upravljajte podešavanjima mape", "map_style_description": "URL do style.json mape tema izgleda", + "memory_cleanup_job": "Čišćenje memorije", + "memory_generate_job": "Generacija memorije", "metadata_extraction_job": "Izvod metapodataka", "metadata_extraction_job_description": "Izvucite informacije o metapodacima iz svake datoteke, kao što su GPS, lica i rezolucija", "metadata_faces_import_setting": "Omogućite (enable) dodavanje lica", @@ -219,7 +222,7 @@ "reset_settings_to_default": "Resetujte podešavanja na podrazumevane vrednosti", "reset_settings_to_recent_saved": "Resetujte podešavanja na nedavno sačuvana podešavanja", "scanning_library": "Skeniranje biblioteke", - "search_jobs": "Traži poslove...", + "search_jobs": "Traži poslove…", "send_welcome_email": "Pošaljite e-poštu dobrodošlice", "server_external_domain_settings": "Eksterni domain", "server_external_domain_settings_description": "Domain za javne deljene veze, uključujući http(s)://", @@ -240,7 +243,7 @@ "storage_template_hash_verification_enabled_description": "Omogućava heš verifikaciju, ne onemogućavajte ovo osim ako niste sigurni u posledice", "storage_template_migration": "Migracija šablona za skladištenje", "storage_template_migration_description": "Primenite trenutni {template} na prethodno otpremljene elemente", - "storage_template_migration_info": "Promene šablona će se primeniti samo na nove datoteke. Da biste retroaktivno primenili šablon na prethodno otpremljene datoteke, pokrenite {job}.", + "storage_template_migration_info": "Šablon za skladištenje će pretvoriti sve ekstenzije u mala slova. Promene šablona će se primeniti samo na nove datoteke. Da biste retroaktivno primenili šablon na prethodno otpremljene datoteke, pokrenite {job}.", "storage_template_migration_job": "Posao migracije skladišta", "storage_template_more_details": "Za više detalja o ovoj funkciji pogledajte Šablon za skladište i njegove implikacije", "storage_template_onboarding_description": "Kada je omogućena, ova funkcija će automatski organizovati datoteke na osnovu šablona koji definiše korisnik. Zbog problema sa stabilnošću ova funkcija je podrazumevano isključena. Za više informacija pogledajte dokumentaciju.", @@ -299,7 +302,7 @@ "transcoding_max_b_frames": "Maksimalni B-kadri", "transcoding_max_b_frames_description": "Više vrednosti poboljšavaju efikasnost kompresije, ali usporavaju kodiranje. Možda nije kompatibilno sa hardverskim ubrzanjem na starijim uređajima. 0 onemogućava B-kadre, dok -1 automatski postavlja ovu vrednost.", "transcoding_max_bitrate": "Maksimalni bitrate", - "transcoding_max_bitrate_description": "Podešavanje maksimalnog bitrate-a može učiniti veličine datoteka predvidljivijim uz manju cenu kvaliteta. Pri 720p, tipične vrednosti su 2600k za VP9 ili HEVC, ili 4500k za H.264. Onemogućeno ako je postavljeno na 0.", + "transcoding_max_bitrate_description": "Podešavanje maksimalnog bitrate-a može učiniti veličine datoteka predvidljivijim uz manju cenu kvaliteta. Pri 720p, tipične vrednosti su 2600 kbit/s za VP9 ili HEVC, ili 4500 kbit/s za H.264. Onemogućeno ako je postavljeno na 0.", "transcoding_max_keyframe_interval": "Maksimalni interval keyframe-a", "transcoding_max_keyframe_interval_description": "Postavlja maksimalnu udaljenost kadrova između ključnih kadrova. Niže vrednosti pogoršavaju efikasnost kompresije, ali poboljšavaju vreme traženja i mogu poboljšati kvalitet scena sa brzim kretanjem. 0 automatski postavlja ovu vrednost.", "transcoding_optimal_description": "Video snimci veći od ciljne rezolucije ili nisu u prihvaćenom formatu", @@ -391,6 +394,7 @@ "allow_edits": "Dozvoli uređenje", "allow_public_user_to_download": "Dozvolite javnom korisniku da preuzme (download-uje)", "allow_public_user_to_upload": "Dozvoli javnom korisniku da otpremi (upload-uje)", + "alt_text_qr_code": "Slika QR koda", "anti_clockwise": "U smeru suprotnom od kazaljke na satu", "api_key": "API ključ (key)", "api_key_description": "Ova vrednost će biti prikazana samo jednom. Obavezno kopirajte pre nego što zatvorite prozor.", @@ -406,17 +410,17 @@ "are_these_the_same_person": "Da li su ovo ista osoba?", "are_you_sure_to_do_this": "Jeste li sigurni da želite ovo da uradite?", "asset_added_to_album": "Dodato u album", - "asset_adding_to_album": "Dodaje se u album...", + "asset_adding_to_album": "Dodaje se u album…", "asset_description_updated": "Opis datoteke je ažuriran", "asset_filename_is_offline": "Datoteka {filename} je van mreže (offline)", "asset_has_unassigned_faces": "Datoteka ima nedodeljena lica", - "asset_hashing": "Heširanje...", + "asset_hashing": "Heširanje…", "asset_offline": "Datoteka odsutna", "asset_offline_description": "Ova vanjska datoteka se više ne nalazi na disku. Molimo kontaktirajte svog Immich administratora za pomoć.", "asset_skipped": "Preskočeno", "asset_skipped_in_trash": "U otpad", "asset_uploaded": "Otpremljeno (Uploaded)", - "asset_uploading": "Otpremanje...", + "asset_uploading": "Otpremanje…", "assets": "Zapisi", "assets_added_count": "Dodato {count, plural, one {# datoteka} other {# datoteka}}", "assets_added_to_album_count": "Dodato je {count, plural, one {# datoteka} other {# datoteka}} u album", @@ -481,6 +485,7 @@ "comments_are_disabled": "Komentari su onemogućeni", "confirm": "Potvrdi", "confirm_admin_password": "Potvrdi Administrativnu Lozinku", + "confirm_delete_face": "Da li ste sigurni da želite da izbrišete osobu {name} iz dela?", "confirm_delete_shared_link": "Da li ste sigurni da želite da izbrišete ovaj deljeni link?", "confirm_keep_this_delete_others": "Sve ostale datoteke u grupi će biti izbrisane osim ove datoteke. Da li ste sigurni da želite da nastavite?", "confirm_password": "Ponovo unesi šifru", @@ -533,6 +538,7 @@ "delete_album": "Obriši album", "delete_api_key_prompt": "Da li ste sigurni da želite da izbrišete ovaj API ključ (key)?", "delete_duplicates_confirmation": "Da li ste sigurni da želite da trajno izbrišete ove duplikate?", + "delete_face": "Izbriši osobu", "delete_key": "Izbriši ključ", "delete_library": "Obriši biblioteku", "delete_link": "Obriši vezu", @@ -600,6 +606,7 @@ "enabled": "Omogućeno (Enabled)", "end_date": "Krajnji datum", "error": "Greška", + "error_delete_face": "Greška pri brisanju osobe iz dela", "error_loading_image": "Greška pri učitavanju slike", "error_title": "Greška – Nešto je pošlo naopako", "errors": { @@ -766,8 +773,10 @@ "go_to_folder": "Idi u fasciklu", "go_to_search": "Idi na pretragu", "group_albums_by": "Grupni albumi po...", + "group_country": "Grupa po država", "group_no": "Bez grupisanja", "group_owner": "Grupirajte po vlasniku", + "group_places_by": "Grupirajte mesta po...", "group_year": "Grupirajte po godini", "has_quota": "Ima kvotu", "hi_user": "Zdravo {name} ({email})", @@ -800,6 +809,7 @@ "include_shared_albums": "Obuhvati deljene albume", "include_shared_partner_assets": "Obuhvati zajedničke datoteke partnera", "individual_share": "Individualni udeo", + "individual_shares": "Pojedinačne akcije", "info": "Informacija", "interval": { "day_at_onepm": "Svaki dan u 1pm", @@ -822,6 +832,7 @@ "latest_version": "Najnovija verzija", "latitude": "Geografska širina", "leave": "Napusti", + "lens_model": "Model sočiva", "let_others_respond": "Dozvoli da drugi komentarišu", "level": "Nivo", "library": "Biblioteka", @@ -880,6 +891,7 @@ "month": "Mesec", "more": "Više", "moved_to_trash": "Premešteno u smeće", + "mute_memories": "Priguši sećanja", "my_albums": "Moji albumi", "name": "Ime", "name_or_nickname": "Ime ili nadimak", @@ -984,6 +996,7 @@ "pick_a_location": "Odaberi lokaciju", "place": "Mesto", "places": "Mesta", + "places_count": "{count, plural, one {{count, number} Mesto} other {{count, number} Mesta}}", "play": "Pokreni", "play_memories": "Pokreni sećanja", "play_motion_photo": "Pokreni pokretnu fotografiju", @@ -1071,6 +1084,8 @@ "removed_from_archive": "Uklonjeno iz arhive", "removed_from_favorites": "Uklonjeno iz omiljenih (favorites)", "removed_from_favorites_count": "{count, plural, other {Uklonjeno #}} iz omiljenih", + "removed_memory": "Uklonjena memorija", + "removed_photo_from_memory": "Slika je uklonjena iz memorije", "removed_tagged_assets": "Uklonjena oznaka iz {count, plural, one {# datoteke} other {# datoteka}}", "rename": "Preimenuj", "repair": "Popravi", @@ -1079,6 +1094,7 @@ "repository": "Repozitorijum (Repository)", "require_password": "Potrebna lozinka", "require_user_to_change_password_on_first_login": "Zahtevati od korisnika da promeni lozinku pri prvom prijavljivanju", + "rescan": "Ponovo skeniraj", "reset": "Resetovati", "reset_password": "Resetovati lozinku", "reset_people_visibility": "Resetujte vidljivost osoba", @@ -1107,18 +1123,22 @@ "search": "Pretraga", "search_albums": "Pretraži albume", "search_by_context": "Pretražujte po kontekstu", + "search_by_description": "Traži po opisu", + "search_by_description_example": "Dan pešačenja u Sapi", "search_by_filename": "Pretražite po imenu datoteke ili ekstenziji", "search_by_filename_example": "npr. IMG_1234.JPG ili PNG", "search_camera_make": "Pretraga proizvođača kamere...", "search_camera_model": "Pretraži model kamere...", "search_city": "Pretraži grad...", "search_country": "Traži zemlju...", + "search_for": "Traži", "search_for_existing_person": "Potražite postojeću osobu", "search_no_people": "Bez osoba", "search_no_people_named": "Nema osoba sa imenom „{name}“", "search_options": "Opcije pretrage", "search_people": "Pretraži osobe", "search_places": "Pretraži mesta", + "search_rating": "Pretraga po oceni...", "search_settings": "Pretraga podešavanja", "search_state": "Traži region...", "search_tags": "Pretraži oznake (tags)...", @@ -1165,6 +1185,7 @@ "shared_from_partner": "Slike od {partner}", "shared_link_options": "Opcije deljene veze", "shared_links": "Deljene veze", + "shared_links_description": "Delite fotografije i video zapise pomoću linka", "shared_photos_and_videos_count": "{assetCount, plural, other {# deljene fotografije i video zapise.}}", "shared_with_partner": "Deli se sa {partner}", "sharing": "Deljenje", @@ -1187,6 +1208,7 @@ "show_person_options": "Prikaži opcije osobe", "show_progress_bar": "Prikaži traku napretka", "show_search_options": "Prikaži opcije pretrage", + "show_shared_links": "Prikaži deljene veze", "show_slideshow_transition": "Prikaži prelaz projekcije slajdova", "show_supporter_badge": "Značka podrške", "show_supporter_badge_description": "Pokažite značku podrške", @@ -1240,6 +1262,7 @@ "tag_created": "Napravljena oznaka (tag): {tag}", "tag_feature_description": "Pregledavanje fotografija i video snimaka grupisanih po logičnim temama oznaka", "tag_not_found_question": "Ne možete da pronađete oznaku (tag)? Napravite novu oznaku", + "tag_people": "Označite ljude", "tag_updated": "Ažurirana oznaka (tag): {tag}", "tagged_assets": "Označeno (tagged) {count, plural, one {# datoteka} other {# datoteke}}", "tags": "Oznake (tags)", @@ -1274,11 +1297,13 @@ "unfavorite": "Izbaci iz omiljenih (unfavorite)", "unhide_person": "Otkrij osobu", "unknown": "Nepoznat", + "unknown_country": "Nepoznata zemlja", "unknown_year": "Nepoznata Godina", "unlimited": "Neograničeno", "unlink_motion_video": "Odveži video od slike", "unlink_oauth": "Prekini vezu sa Oauth-om", "unlinked_oauth_account": "Opozvana veza OAuth naloga", + "unmute_memories": "Uključi uspomene", "unnamed_album": "Neimenovani album", "unnamed_album_delete_confirmation": "Da li ste sigurni da želite da izbrišete ovaj album?", "unnamed_share": "Neimenovano delenje", @@ -1332,6 +1357,7 @@ "view_all": "Prikaži Sve", "view_all_users": "Prikaži sve korisnike", "view_in_timeline": "Prikaži u vremenskoj liniji", + "view_link": "Pogledaj vezu", "view_links": "Prikaži veze", "view_name": "Pogledati", "view_next_asset": "Pogledajte sledeću datoteku", @@ -1348,4 +1374,4 @@ "yes": "Da", "you_dont_have_any_shared_links": "Nemate nijedno deljenje veze", "zoom_image": "Zumiraj sliku" -} +} \ No newline at end of file diff --git a/i18n/sv.json b/i18n/sv.json index 80f2687b7d..f57d112c34 100644 --- a/i18n/sv.json +++ b/i18n/sv.json @@ -20,7 +20,7 @@ "add_partner": "Lägg till partner", "add_path": "Lägg till sökväg", "add_photos": "Lägg till foton", - "add_to": "Lägg till...", + "add_to": "Lägg till i…", "add_to_album": "Lägg till i album", "add_to_shared_album": "Lägg till i delat album", "add_url": "Lägg till URL", @@ -41,6 +41,7 @@ "backup_settings": "Säkerhetskopieringsinställningar", "backup_settings_description": "Hantera inställningar för säkerhetskopiering av databas", "check_all": "Välj alla", + "cleanup": "Uppstädning", "cleared_jobs": "Rensade jobben för:{job}", "config_set_by_file": "Konfigurationen är satt av en konfigurationsfil", "confirm_delete_library": "Är du säker på att du vill radera {library} album?", @@ -59,7 +60,7 @@ "external_library_management": "Hantera externa bibliotek", "face_detection": "Ansiktsdetektering", "face_detection_description": "Identifiera ansikten i foton med hjälp av maskininlärning. För videor används endast miniatyrbilden. \"Alla\" gör om sökningen för alla objekt. \"Saknade\" letar i de objekt som ännu inte sökts igenom. Alla ansikten som identifierats läggs sedan i jobbkön för ansiktsigenkänning där de mappas till nya eller befintliga personer.", - "facial_recognition_job_description": "Gruppera upptäckta ansikten till personer. Det här steget körs efter att ansiktsigenkänning är klar. \"Alla\" (åter-) grupperar alla ansikten. \"Saknade\" köer ansikten som inte har en person tilldelad.", + "facial_recognition_job_description": "Gruppera upptäckta ansikten till personer. Det här steget körs efter att ansiktsdetektering är klar. \"Alla\" (åter-) grupperar alla ansikten. \"Saknade\" köer ansikten som inte har en person tilldelad.", "failed_job_command": "Kommando {command} misslyckades för jobb: {job}", "force_delete_user_warning": "VARNING: Detta tar omedelbart bort användaren och alla mediafiler. Detta kan inte ångras och filerna kan inte återställas.", "forcing_refresh_library_files": "Tvingar uppdatering av alla biblioteksfiler", @@ -96,7 +97,7 @@ "library_scanning_enable_description": "Aktivera periodisk biblioteksskanning", "library_settings": "Externa bibliotek", "library_settings_description": "Hantera inställningar för externa bibliotek", - "library_tasks_description": "Kör biblioteksjobb", + "library_tasks_description": "Sök igenom externa bibliotek efter nya och/eller ändrade objekt", "library_watching_enable_description": "Titta på externa bibliotek för filändringar", "library_watching_settings": "Titta på bibliotek (EXPERIMENTELLT)", "library_watching_settings_description": "Titta automatiskt efter ändrade filer", @@ -131,7 +132,7 @@ "machine_learning_smart_search_description": "Sök semantiskt efter bilder med hjälp av CLIP-inbäddningar", "machine_learning_smart_search_enabled": "Aktivera smart sökning", "machine_learning_smart_search_enabled_description": "Om inaktiverat kommer bilder inte att kodas för smart sökning.", - "machine_learning_url_description": "Maskininlärningsserverns URL. Om det är mer än en URL tillagd så kommer ett försök per URL att utföras tills någon av dom svarar, försöken görs i kronologisk ordning.", + "machine_learning_url_description": "Maskininlärningsserverns URL. Om det är mer än en URL tillagd så kommer ett försök per URL att utföras tills någon av dom svarar, försöken görs i kronologisk ordning. Servrar som inte svarar kommer tillfälligt ignoreras tills de är nåbara igen.", "manage_concurrency": "Hantera samtidighet", "manage_log_settings": "Hantera logginställningar", "map_dark_style": "Mörk stil", @@ -147,6 +148,8 @@ "map_settings": "Karta", "map_settings_description": "Hantera kartinställningar", "map_style_description": "URL till en style.json-karto tema", + "memory_cleanup_job": "Rensa minnen", + "memory_generate_job": "Generera minnen", "metadata_extraction_job": "Extrahera metadata", "metadata_extraction_job_description": "Läs in metadata (t.ex. GPS, ansikten och upplösning) för varje resurs", "metadata_faces_import_setting": "Aktivera import av ansikten", @@ -219,7 +222,7 @@ "reset_settings_to_default": "Återställ inställningar till standard", "reset_settings_to_recent_saved": "Återställ inställningar till de senaste sparade", "scanning_library": "Skanna bibliotek", - "search_jobs": "Sök Jobb...", + "search_jobs": "Sökjobb…", "send_welcome_email": "Skicka välkomstmail", "server_external_domain_settings": "Extern domän", "server_external_domain_settings_description": "Domän för publikt delade länkar, inklusive http(s)://", @@ -240,7 +243,7 @@ "storage_template_hash_verification_enabled_description": "Aktiverar hash-verifiering, deaktiviera inte om du inte är säker på implikationerna", "storage_template_migration": "Migrering av Lagringsmallar", "storage_template_migration_description": "Applicera aktiv {template} till tidigare uppladdade resurser", - "storage_template_migration_info": "Ändringar i mall gäller endast nya resurser. För att retoaktivt tillämpa mallen på tidigare uppladdade resurser kör {job}.", + "storage_template_migration_info": "Lagringsmallen kommer konvertera alla filändelser till gemena bokstäver. Ändringar gäller endast för nya resurser, för att retoaktivt tillämpa mallen på befintliga resurser kör {job}.", "storage_template_migration_job": "Lagringsmall migreringsjobb", "storage_template_more_details": "För mer information om den här funktionen se Lagringsmall och dess konsekvenser", "storage_template_onboarding_description": "Vid aktivering organiserar denna funktion automatiskt filer baserat på en användardefinierad mall. På grunda av stabilitetsproblem är denna funktion avstängd som standard, för mer information se dokumentation.", @@ -291,15 +294,15 @@ "transcoding_disabled_description": "Omkoda inte videofiler, detta kan störa uppspelning på vissa klienter", "transcoding_encoding_options": "Kodningsval", "transcoding_encoding_options_description": "Välj codec, upplösning, kvalitet och andra val för kodade videor", - "transcoding_hardware_acceleration": "Hardvaruacceleration", - "transcoding_hardware_acceleration_description": "Forskningsmässig; betydligt snabbare men med lägre kvalitet vid samma biträtta", + "transcoding_hardware_acceleration": "Hårdvaruacceleration", + "transcoding_hardware_acceleration_description": "Experimentell; betydligt snabbare men med lägre kvalitet vid samma bittakt", "transcoding_hardware_decoding": "Hårdvaruavkodning", "transcoding_hardware_decoding_setting_description": "Tillämpas enbart på NVENC, QSV och RKMPP. Aktiverar end-to-end accelerering i stället för endast kodningsacceleration. Fungerar inte med alla videor.", "transcoding_hevc_codec": "HEVC-codec", "transcoding_max_b_frames": "Max B-ramar", "transcoding_max_b_frames_description": "Högre värden förbättrar kompressionseffektiviteten, men saktar ner kodningen. Kan vara inkompatibel med hårdvaruacceleration på äldre enheter. 0 avaktiverar B-frames, medan -1 anger detta värde automatiskt.", "transcoding_max_bitrate": "Max bithastighet", - "transcoding_max_bitrate_description": "En maximal bitrate kan göra filstorlekar mer förutsägbara till en liten kostnad på kvalitet. Vid 720p är typiska värden 2600k för VP9 eller HEVC, eller 4500k för H.264. Inaktiverad om satt till 0.", + "transcoding_max_bitrate_description": "En maximal bitrate kan göra filstorlekar mer förutsägbara till en liten kostnad på kvalitet. Vid 720p är typiska värden 2600 kbit/s för VP9 eller HEVC, eller 4500 kbit/s för H.264. Inaktiverad om satt till 0.", "transcoding_max_keyframe_interval": "Max nyckelbildruteintervall", "transcoding_max_keyframe_interval_description": "Sätter det maximala bildruteavståndet mellan nyckelbildrutor. Lägre värden försämrar kompressionseffektiviteten, men förbättrar söktiderna och kan förbättra kvaliteten i scener med snabb rörelse. 0 ställer in detta värde automatiskt.", "transcoding_optimal_description": "Videor som är högre än mållösning eller inte i ett accepterat format", @@ -362,7 +365,7 @@ "advanced": "Avancerat", "age_months": "Ålder {months, plural, one {# month} other {# months}}", "age_year_months": "Ålder 1 år, {months, plural, one {# month} other {# months}}", - "age_years": "{years, plural, other {Age #}}", + "age_years": "{years, plural, other {Ålder #}}", "album_added": "Albumet har lagts till", "album_added_notification_setting_description": "Få ett e-postmeddelande när du läggs till i ett delat album", "album_cover_updated": "Albumomslaget uppdaterat", @@ -382,7 +385,7 @@ "album_user_removed": "Tog bort {user}", "album_with_link_access": "Låt alla med länken se foton och personer i det här albumet.", "albums": "Album", - "albums_count": "{count, plural, one {{count, number} Album} other {{count, number} Albums}}", + "albums_count": "{count, plural, one {{count, number} Album} other {{count, number} Album}}", "all": "Allt", "all_albums": "Alla album", "all_people": "Alla personer", @@ -391,6 +394,7 @@ "allow_edits": "Tillåt redigeringar", "allow_public_user_to_download": "Tillåt offentlig användare att ladda ner", "allow_public_user_to_upload": "Tillåt en offentlig användare att ladda upp", + "alt_text_qr_code": "QR-kod", "anti_clockwise": "Moturs", "api_key": "API Nyckel", "api_key_description": "Detta värde kommer bara att visas en gång. Se till att kopiera det innan du stänger fönstret.", @@ -402,26 +406,26 @@ "archive_or_unarchive_photo": "Arkivera eller oarkivera fotot", "archive_size": "Arkivstorlek", "archive_size_description": "Konfigurera arkivstorleken för nedladdningar (i GiB)", - "archived_count": "{count, plural, other {Archived #}}", + "archived_count": "{count, plural, other {Arkiverade #}}", "are_these_the_same_person": "Är det samma person?", "are_you_sure_to_do_this": "Är du säker på att du vill göra det här?", "asset_added_to_album": "Lades till i album", - "asset_adding_to_album": "Lägger till i album...", + "asset_adding_to_album": "Lägger till i album...…", "asset_description_updated": "Tillgångens beskrivning har uppdaterats", "asset_filename_is_offline": "Tillgången {filename} är offline", "asset_has_unassigned_faces": "Tillgången har otilldelade ansikten", - "asset_hashing": "Hashing...", + "asset_hashing": "Hashing...…", "asset_offline": "Tillgång offline", "asset_offline_description": "Denna externa tillgång finns inte längre på disken. Kontakta din Immich-administratör för hjälp.", "asset_skipped": "Överhoppad", "asset_skipped_in_trash": "I papperskorgen", "asset_uploaded": "Uppladdad", - "asset_uploading": "Laddar upp...", + "asset_uploading": "Laddar upp...…", "assets": "Objekt", "assets_added_count": "La till {count, plural, one {# asset} other {# assets}}", "assets_added_to_album_count": "Lade till {count, plural, one {# asset} other {# assets}} i albumet", - "assets_added_to_name_count": "Lade till {count, plural, one {# asset} other {# assets}} till {hasName, select, true {{name}} other {new album}}", - "assets_count": "{count, plural, one {# asset} other {# assets}}", + "assets_added_to_name_count": "Lade till {count, plural, one {# objekt} other {# objekt}} till {hasName, select, true {{name}} other {nytt album}}", + "assets_count": "{count, plural, one {# objekt} other {# objekt}}", "assets_moved_to_trash_count": "Flyttade {count, plural, one {# asset} other {# assets}} till papperskorgen", "assets_permanently_deleted_count": "Raderad permanent {count, plural, one {# asset} other {# assets}}", "assets_removed_count": "Tog bort {count, plural, one {# asset} other {# assets}}", @@ -481,6 +485,7 @@ "comments_are_disabled": "Kommentarer är avstängda", "confirm": "Bekräfta", "confirm_admin_password": "Bekräfta administratörslösenord", + "confirm_delete_face": "Är du säker på att du vill ta bort {name}'s ansikte från objektet?", "confirm_delete_shared_link": "Är du säker på att du vill ta bort den här delade länken?", "confirm_keep_this_delete_others": "Alla tillgångar förutom den här tas bort från stacken. Är du säker på att du vill fortsätta?", "confirm_password": "Bekräfta lösenord", @@ -526,12 +531,14 @@ "deduplication_criteria_1": "Bildstorlek i bytes", "deduplication_criteria_2": "Räkning av EXIF-data", "deduplication_info": "Dedupliceringsinformation", + "deduplication_info_description": "För att automatiskt välja filer och ta bort dubletter i bulk analyserar vi:", "default_locale": "Standardplats", "default_locale_description": "Formatera datum och siffror baserat på din webbläsares språkversion", "delete": "Radera", "delete_album": "Ta bort album", "delete_api_key_prompt": "Är du säker på att du vill ta bort denna API-nyckel?", "delete_duplicates_confirmation": "Är du säker på att du vill ta bort dessa dubbletter permanent?", + "delete_face": "Ta bort ansikte", "delete_key": "Ta bort nyckel", "delete_library": "Ta bort bibliotek", "delete_link": "Ta bort länk", @@ -599,13 +606,14 @@ "enabled": "Aktiverad", "end_date": "Slutdatum", "error": "Fel", + "error_delete_face": "Fel uppstod när ansikte skulle tas bort från objektet", "error_loading_image": "Fel vid bildladdning", "error_title": "Fel – något gick fel", "errors": { "cannot_navigate_next_asset": "Det går inte att navigera till nästa objekt", "cannot_navigate_previous_asset": "Det går inte att navigera till föregående objekt", "cant_apply_changes": "Det går inte att tillämpa ändringar", - "cant_change_activity": "Kan inte {enabled, select, true {disable} other {enable}} aktivitet", + "cant_change_activity": "Kan inte {enabled, select, true {avaktivera} other {aktivera}} aktivitet", "cant_change_asset_favorite": "Det går inte att byta favorit mot objekt", "cant_change_metadata_assets_count": "Det går inte att ändra metadata för {count, plural, one {# asset} other {# assets}}", "cant_get_faces": "Kan inte få ansikten", @@ -645,7 +653,7 @@ "unable_to_add_exclusion_pattern": "Det gick inte att lägga till uteslutningsmönster", "unable_to_add_import_path": "Det gick inte att lägga till importsökväg", "unable_to_add_partners": "Kunde inte lägga till partners", - "unable_to_add_remove_archive": "Det går inte att {archived, select, true {remove asset from} other {add asset to}} arkiv", + "unable_to_add_remove_archive": "Det går inte att {archived, select, true {ta bort objekt från} other {lägga till objekt till}} arkiv", "unable_to_add_remove_favorites": "Det går inte att {favorite, select, true {add asset to} other {remove asset from}} favoriter", "unable_to_archive_unarchive": "Det går inte att {archived, select, true {archive} other {archive}}", "unable_to_change_album_user_role": "Kunde inte ändra albumanvändarens roll", @@ -765,8 +773,10 @@ "go_to_folder": "Gå till mapp", "go_to_search": "Gå till sök", "group_albums_by": "Gruppera album efter...", + "group_country": "Gruppera per land", "group_no": "Ingen gruppering", "group_owner": "Grupper efter ägare", + "group_places_by": "Gruppera platser efter…", "group_year": "Gruppera efter årtal", "has_quota": "Har kvot", "hi_user": "Hej {name} ({email})", @@ -779,7 +789,7 @@ "host": "Värd", "hour": "Timme", "image": "Bild", - "image_alt_text_date": "{isVideo, select, true {Video} other {Image}} tagen {date}", + "image_alt_text_date": "{isVideo, select, true {Video} other {Bild}} tagen {date}", "image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Image}} tagen med {person1} den {date}", "image_alt_text_date_2_people": "{isVideo, select, true {Video} other {Image}} tagen med {person1} och {person2} den {date}", "image_alt_text_date_3_people": "{isVideo, select, true {Video} other {Image}} tagen med {person1}, {person2}, och {person3} den {date}", @@ -799,6 +809,7 @@ "include_shared_albums": "Inkludera delade album", "include_shared_partner_assets": "Inkludera delade partners tillgångar", "individual_share": "Enskild delning", + "individual_shares": "Individuella delningar", "info": "Information", "interval": { "day_at_onepm": "Alla dagar vid kl 13.00", @@ -808,7 +819,7 @@ }, "invite_people": "Bjud in personer", "invite_to_album": "Bjuder in till album", - "items_count": "{count, plural, one {# item} other {# items}}", + "items_count": "{count, plural, one {# objekt} other {# objekt}}", "jobs": "Jobb", "keep": "Behåll", "keep_all": "Behåll alla", @@ -821,6 +832,7 @@ "latest_version": "Senaste versionen", "latitude": "Latitud", "leave": "Lämna", + "lens_model": "Objektiv", "let_others_respond": "Låt andra svara", "level": "Nivå", "library": "Bibliotek", @@ -879,6 +891,7 @@ "month": "Månad", "more": "Mer", "moved_to_trash": "Flyttad till papperskorgen", + "mute_memories": "Tysta minnen", "my_albums": "Mina album", "name": "Namn", "name_or_nickname": "Namn eller smeknamn", @@ -970,8 +983,13 @@ "permanent_deletion_warning_setting_description": "Visa en varning när tillgångar raderas permanent", "permanently_delete": "Radera permanent", "permanently_delete_assets_count": "Radera {count, plural, one {asset} other {assets}} permanent", + "permanently_delete_assets_prompt": "Är du säker på att du permanent vill ta bort {count, plural, one {denna fil?} other{these # filer?}} Detta kommer också ta bort {count, plural, one {dem från } other{them from their}} album.", "permanently_deleted_asset": "Permanent raderad tillgång", + "permanently_deleted_assets_count": "Permanent borttagning av {count, plural, one {# asset} other {# assets}}", "person": "Person", + "person_birthdate": "Född {date}", + "person_hidden": "{name}{hidden, select, true { (dold)} other {}}", + "photo_shared_all_users": "Du har antingen delat dina foton med alla användare eller så har du inga användare att dela dem med.", "photos": "Foton", "photos_and_videos": "Foton & videor", "photos_count": "{count, plural, one {{count, number} Foto} other {{count, number} Foton}}", @@ -979,6 +997,7 @@ "pick_a_location": "Välj en plats", "place": "Plats", "places": "Platser", + "places_count": "{count, plural, one {{count, number} Plats} other {{count, number} Platser}}", "play": "Spela upp", "play_memories": "Spela upp minnen", "play_motion_photo": "Spela upp rörligt foto", @@ -990,10 +1009,11 @@ "previous_memory": "Föregående minne", "previous_or_next_photo": "Föregående eller nästa foto", "primary": "Primär", + "privacy": "Sekretess", "profile_image_of_user": "{user} profilbild", "profile_picture_set": "Profilbild vald.", "public_album": "Publikt album", - "public_share": "", + "public_share": "Offentlig delning", "purchase_account_info": "Supporter", "purchase_activated_subtitle": "Tack för att du stödjer Immich och open source-mjukvara", "purchase_activated_time": "Aktiverad {date, date}", @@ -1016,129 +1036,237 @@ "purchase_panel_info_1": "Att bygga Immich kräver mycket tid och engagemang och våra tekniker jobbar heltid för att göra det så bra som vi möjligt kan. Vårt mål är att open source-mjukvara och etiska affärsmetoder ska bli en hållbar inkomstkälla för utvecklare och att skapa ett ekosystem som repekterar personlig integritet med verkliga alternativ till exploaterande molntjänster.", "purchase_panel_info_2": "Då vi åtagit oss att inte ha betalväggar kommer detta köp inte att ge dig några utökade funktioner i Immich. Vi sätter vår tillit till användare som du som stödjer Immichs fortsatta utveckling.", "purchase_panel_title": "Stöd projektet", + "purchase_per_server": "Per server", + "purchase_per_user": "Per användare", "purchase_remove_product_key": "Ta bort produktnyckel", "purchase_remove_product_key_prompt": "Vill du verkligen ta bort produktnyckeln?", + "purchase_remove_server_product_key": "Ta bort serverns produktnyckel", + "purchase_remove_server_product_key_prompt": "Är du säker på att du vill ta bort serverns produktnyckel?", + "purchase_server_description_1": "För hela servern", "purchase_server_description_2": "Supporterstatus", "purchase_server_title": "Server", "purchase_settings_server_activated": "Produktnyckeln för servern hanteras av administratören", - "reaction_options": "", + "rating": "Antal stjärnor", + "rating_clear": "Ta bort betyg", + "rating_count": "{count, plural, one {# stjärna} other {# stjärnor}}", + "rating_description": "Visa EXIF betyget i informationspanelen", + "reaction_options": "Alternativ för reaktion", "read_changelog": "Läs ändringslogg", - "recent": "", - "recent_searches": "", + "reassign": "Omfördela", + "reassigned_assets_to_existing_person": "Tilldelade om {count, plural, one {# objekt} other {# objekt}} till {name, select, null {an existing person} other {{name}}}", + "reassigned_assets_to_new_person": "Tilldelade om {count, plural, one {# objekt} other {# objekt}} till en ny persson", + "reassing_hint": "Tilldela valda tillgångar till en befintlig person", + "recent": "Nyligen", + "recent-albums": "Senaste album", + "recent_searches": "Senaste sökningar", "refresh": "Ladda om", "refresh_encoded_videos": "Ladda om kodade videor", "refresh_faces": "Ladda om ansikten", "refresh_metadata": "Ladda om metadata", + "refresh_thumbnails": "Uppdatera miniatyrer", "refreshed": "Omladdad", "refreshes_every_file": "Läser in alla existerande och nya filer på nytt", "refreshing_encoded_video": "Återladdar kodad video", "refreshing_faces": "Återladdar ansikten", "refreshing_metadata": "Återladdar metadata", + "regenerating_thumbnails": "Uppdaterar miniatyrer", "remove": "Ta bort", + "remove_assets_album_confirmation": "Är du säker på att du vill ta bort {count, plural, one {# asset} other {# assets}} från albumet?", + "remove_assets_shared_link_confirmation": "Är du säker på att du vill ta bort {count, plural, one {# asset} other {# assets}} från denna delade länk?", "remove_assets_title": "Ta bort filer?", - "remove_deleted_assets": "", + "remove_custom_date_range": "Ta bort anpassat datumintervall", + "remove_deleted_assets": "Ta bort borttagna tillgångar", "remove_from_album": "Ta bort från album", - "remove_from_favorites": "", - "remove_from_shared_link": "", - "repair": "", - "repair_no_results_message": "", - "replace_with_upload": "", - "require_password": "", - "reset": "", - "reset_password": "", - "reset_people_visibility": "", + "remove_from_favorites": "Ta bort från favoriter", + "remove_from_shared_link": "Ta bort från delad länk", + "remove_url": "Ta bort URL", + "remove_user": "Ta bort användare", + "removed_api_key": "Tog bort API nyckel: {name}", + "removed_from_archive": "Borttagen från arkivet", + "removed_from_favorites": "Borttagen från favoriter", + "removed_from_favorites_count": "{count, plural, other {Tog bort #}} från favoriter", + "removed_memory": "Tog bort minne", + "removed_photo_from_memory": "Tog bort foto från minnet", + "removed_tagged_assets": "Tog bort tagg från {count, plural, one {# objekt} other {# objekt}}", + "rename": "Döp om", + "repair": "Reparera", + "repair_no_results_message": "Ospårade och saknade filer kommer att dyka upp här", + "replace_with_upload": "Ersätt med uppladdning", + "repository": "Förvar", + "require_password": "Kräver lösenord", + "require_user_to_change_password_on_first_login": "Kräv att användaren ändrar lösenord vid första inloggning", + "rescan": "Skanna igen", + "reset": "Återställ", + "reset_password": "Nollställ lösenord", + "reset_people_visibility": "Återställ personers synlighet", + "reset_to_default": "Återställ till standard", + "resolve_duplicates": "Lös dubletter", + "resolved_all_duplicates": "Lös alla dubletter", "restore": "Återställ", - "restore_user": "", - "retry_upload": "", + "restore_all": "Återställ alla", + "restore_user": "Återställ användare", + "restored_asset": "Återställ tillgång", + "resume": "Återuppta", + "retry_upload": "Ladda upp igen", "review_duplicates": "Granska dubbletter", - "role": "", + "role": "Roll", + "role_editor": "Redigerare", + "role_viewer": "Visare", "save": "Spara", - "saved_profile": "", - "saved_settings": "", + "saved_api_key": "Sparad API-nyckel", + "saved_profile": "Sparade profil", + "saved_settings": "Sparade inställningar", "say_something": "Säg något", "scan_all_libraries": "Skanna alla bibliotek", - "scan_settings": "", + "scan_library": "Skanna", + "scan_settings": "Skanningsinställningar", + "scanning_for_album": "Söker efter album...", "search": "Sök", - "search_albums": "", + "search_albums": "Sök album", "search_by_context": "Sök efter sammanhang", - "search_camera_make": "", - "search_camera_model": "", - "search_city": "", - "search_country": "", - "search_for_existing_person": "", - "search_people": "", - "search_places": "", - "search_state": "", - "search_timezone": "", + "search_by_description": "Sök via beskrivning", + "search_by_description_example": "Vandringsdag i Sapa", + "search_by_filename": "Sök efter filnamn eller filändelse", + "search_by_filename_example": "t.ex. IMG_1234.JPG eller PNG", + "search_camera_make": "Sök efter kameratillverkare...", + "search_camera_model": "Sök efter kameramodell...", + "search_city": "Sök efter stad...", + "search_country": "Sök efter land...", + "search_for": "Sök efter", + "search_for_existing_person": "Sök efter befintlig person", + "search_no_people": "Inga personer", + "search_no_people_named": "Inga personer med namnet \"{name}\"", + "search_options": "Sökinställningar", + "search_people": "Sök personer", + "search_places": "Sök platser", + "search_rating": "Sök efter betyg...", + "search_settings": "Sök inställningar", + "search_state": "Sök stat...", + "search_tags": "Sök taggar...", + "search_timezone": "Sök tidszon...", "search_type": "Söktyp", "search_your_photos": "Sök bland dina foton", - "searching_locales": "", - "second": "", + "searching_locales": "Söker efter språk...", + "second": "Sekund", "see_all_people": "Se alla personer", - "select_album_cover": "", - "select_all": "", - "select_avatar_color": "", - "select_face": "", - "select_featured_photo": "", - "select_library_owner": "", - "select_new_face": "", + "select_album_cover": "Välj albumomslag", + "select_all": "Välj alla", + "select_all_duplicates": "Välj alla dubletter", + "select_avatar_color": "Välj färg för avatar", + "select_face": "Välj person", + "select_featured_photo": "Välj utvald bild", + "select_from_computer": "Välj från datorn", + "select_keep_all": "Spara alla", + "select_library_owner": "Välj biblioteksägare", + "select_new_face": "Välj nytt ansikte", "select_photos": "Välj foton", - "selected": "", - "send_message": "", + "select_trash_all": "Släng alla", + "selected": "Valda", + "selected_count": "{count, plural, other {# valda}}", + "send_message": "Skicka meddelande", + "send_welcome_email": "Skicka välkomstmejl", + "server_offline": "Servern offline", + "server_online": "Server online", "server_stats": "Serverstatistik", - "set": "", - "set_as_album_cover": "", + "server_version": "Serverversion", + "set": "Välj", + "set_as_album_cover": "Ange som albumomslag", + "set_as_featured_photo": "Ställ in som utvalt foto", "set_as_profile_picture": "Ange som profilbild", - "set_date_of_birth": "", - "set_profile_picture": "", - "set_slideshow_to_fullscreen": "", + "set_date_of_birth": "Ange födelsedatum", + "set_profile_picture": "Ange som profilbild", + "set_slideshow_to_fullscreen": "Ställ in bildspel på helskärm", "settings": "Inställningar", - "settings_saved": "", + "settings_saved": "Inställningar sparade", "share": "Dela", "shared": "Delad", - "shared_by": "", - "shared_by_you": "", + "shared_by": "Delad av", + "shared_by_user": "Delad av {user}", + "shared_by_you": "Delad av dig", + "shared_from_partner": "Foton från {partner}", + "shared_link_options": "Alternativ för delad länk", "shared_links": "Delade Länkar", + "shared_links_description": "Dela foton och videor med en länk", + "shared_photos_and_videos_count": "{assetCount, plural, other {# delade foton och videor.}}", + "shared_with_partner": "Delad med {partner}", "sharing": "Delning", - "sharing_sidebar_description": "", - "show_album_options": "", - "show_file_location": "", - "show_gallery": "", - "show_hidden_people": "", - "show_in_timeline": "", - "show_in_timeline_setting_description": "", - "show_keyboard_shortcuts": "", + "sharing_enter_password": "Ange lösenord för att visa denna sidan.", + "sharing_sidebar_description": "Visa en länk till Delning i sidopanelen", + "shift_to_permanent_delete": "tryck på ⇧ för att permanent radera tillgången", + "show_album_options": "Visa albumalternativ", + "show_albums": "Visa album", + "show_all_people": "Visa alla personer", + "show_and_hide_people": "Visa & göm personer", + "show_file_location": "Visa sökväg", + "show_gallery": "Visa galleri", + "show_hidden_people": "Visa gömda personer", + "show_in_timeline": "Visa på tidslinje", + "show_in_timeline_setting_description": "Visa foton och videor från denna användaren på din tidslinje", + "show_keyboard_shortcuts": "Visa kortkommandon", "show_metadata": "Visa metadata", - "show_or_hide_info": "", - "show_password": "", - "show_person_options": "", - "show_progress_bar": "", - "show_search_options": "", - "shuffle": "", + "show_or_hide_info": "Visa eller göm information", + "show_password": "Visa lösenord", + "show_person_options": "Visa alternativ för person", + "show_progress_bar": "Visa förloppsindikator", + "show_search_options": "Visa sökalternativ", + "show_shared_links": "Visa delade länkar", + "show_slideshow_transition": "Visa bildspelsövergång", + "show_supporter_badge": "Supporteremblem", + "show_supporter_badge_description": "Visa supporteremblem", + "shuffle": "Blanda", + "sidebar": "Sidopanel", + "sidebar_display_description": "Visa en länk till vyn i sidofältet", "sign_out": "Logga ut", - "sign_up": "", - "size": "", - "skip_to_content": "", - "slideshow": "", - "slideshow_settings": "", - "sort_albums_by": "", + "sign_up": "Registrera dig", + "size": "Storlek", + "skip_to_content": "Hoppa till innehåll", + "skip_to_folders": "Hoppa till mapp", + "skip_to_tags": "Hoppa till taggar", + "slideshow": "Bildspel", + "slideshow_settings": "Bildspelsinställningar", + "sort_albums_by": "Sortera album efter...", + "sort_created": "Skapat datum", + "sort_items": "Antal artiklar", + "sort_modified": "Datum ändrat", + "sort_oldest": "Äldsta foto", + "sort_people_by_similarity": "Sortera människor efter likhet", + "sort_recent": "Senaste fotot", + "sort_title": "Rubrik", + "source": "Källa", "stack": "Stapel", - "stack_selected_photos": "", - "stacktrace": "", + "stack_duplicates": "Stapla dubletter", + "stack_select_one_photo": "Välj ett huvudfoto för stapeln", + "stack_selected_photos": "Stapla valda foton", + "stacked_assets_count": "Staplade {count, plural, one {# asset} other {# assets}}", + "stacktrace": "Stapelspårning", "start": "Starta", "start_date": "Startdatum", "state": "Stat", "status": "Status", - "stop_motion_photo": "", + "stop_motion_photo": "Stanna rörligt foto", "stop_photo_sharing": "Sluta dela dina foton?", + "stop_photo_sharing_description": "{partner} kommer inte länga ha tillgång till dina foton.", + "stop_sharing_photos_with_user": "Sluta dela dina bilder med denna användaren", "storage": "Lagring", - "storage_label": "", + "storage_label": "Förvaringsetikett", "storage_usage": "{used} av {available} används", - "submit": "", + "submit": "Skicka", "suggestions": "Förslag", "sunrise_on_the_beach": "Soluppgång på stranden", - "swap_merge_direction": "", + "support": "Support", + "support_and_feedback": "Support & Feedback", + "support_third_party_description": "Din Immich-installation paketerades av en tredje part. Problem som du upplever kan orsakas av det paketet, så vänligen ta upp problem med dem i första hand med hjälp av länkarna nedan.", + "swap_merge_direction": "Byt sammanfogningsriktning", "sync": "Synka", + "tag": "Tagg", + "tag_assets": "Tagga tillgångar", + "tag_created": "Skapade tagg: {tag}", + "tag_feature_description": "Bläddra bland foton och videor grupperade efter logiska taggar", + "tag_not_found_question": "Kan du inte hitta en tagg? Skapa en ny tagg.", + "tag_people": "Tagga Personer", + "tag_updated": "Uppdaterade tagg: {tag}", + "tagged_assets": "Taggade {count, plural, one {# objekt} other {# objekt}}", + "tags": "Taggar", "template": "Mall", "theme": "Tema", "theme_selection": "Val av tema", @@ -1146,44 +1274,68 @@ "they_will_be_merged_together": "De kommer att slås samman", "third_party_resources": "Tredjepartsresurser", "time_based_memories": "Tidsbaserade minnen", + "timeline": "Tidslinje", "timezone": "Tidszon", "to_archive": "Arkivera", "to_change_password": "Ändra lösenord", "to_favorite": "Favorit", "to_login": "Logga in", + "to_parent": "Gå till förälder", "to_trash": "Papperskorg", - "toggle_settings": "", + "toggle_settings": "Växla inställningar", "toggle_theme": "Växla tema", + "total": "Total", "total_usage": "Total användning", "trash": "Papperskorg", "trash_all": "Kasta alla", + "trash_count": "Papperskorg {count, number}", + "trash_delete_asset": "Papperskorgen/Ta bort tillgång", "trash_no_results_message": "Borttagna foton och videor kommer att visas här.", "trashed_items_will_be_permanently_deleted_after": "Objekt i papperskorgen raderas permanent efter {days, plural, one {# dag} other {# dagar}}.", "type": "Typ", "unarchive": "Ångra arkivering", + "unarchived_count": "{count, plural, other {Unarchived #}}", "unfavorite": "Avfavorisera", - "unhide_person": "", + "unhide_person": "Visa person", "unknown": "Okänd", + "unknown_country": "Okänt Land", "unknown_year": "Okänt år", "unlimited": "Obegränsat", + "unlink_motion_video": "Ta bort länken till rörlig video", "unlink_oauth": "Ta bort länken till OAuth", - "unlinked_oauth_account": "", + "unlinked_oauth_account": "Olänkat OAuth-konto", + "unmute_memories": "Slå på ljud för minnen", + "unnamed_album": "Namnlöst Album", + "unnamed_album_delete_confirmation": "Är du säker på att du vill ta bort detta album?", + "unnamed_share": "Namnlös delning", "unsaved_change": "Osparade ändringar", - "unselect_all": "", + "unselect_all": "Avmarkera alla", + "unselect_all_duplicates": "Avmarkera alla dubletter", "unstack": "Stapla Av", + "unstacked_assets_count": "Avstaplade {count, plural, one {# asset} other {# assets}}", + "untracked_files": "Ospårade filer", + "untracked_files_decription": "Dessa filer spåras inte av applikationen. Det kan bero på misslyckad flytt, avbruten uppladdning eller att de lämnats kvar på grund av en bugg", "up_next": "Nästa", "updated_password": "Lösenordet har uppdaterats", "upload": "Ladda upp", - "upload_concurrency": "", + "upload_concurrency": "Uppladdning samtidighet", + "upload_errors": "Uppladdning klar med {count, plural, one {# fel} other {# fel}}, ladda om sidan för att se nya objekt.", + "upload_progress": "Återstående {remaining, number} - Bearbetade {processed, number}/{total, number}", + "upload_skipped_duplicates": "Hoppade över {count, plural, one {# dublett} other {# dubletter}}", "upload_status_duplicates": "Dubbletter", "upload_status_errors": "Fel", + "upload_status_uploaded": "Uppladdad", + "upload_success": "Uppladdning lyckades, ladda om sidan för att se nya objekt.", "url": "URL", "usage": "Användning", + "use_custom_date_range": "Använd anpassat datumintervall istället", "user": "Användare", "user_id": "Användar-ID", + "user_liked": "{user} gillade {type, select, photo {detta fotot} video {denna filmen} asset {detta objekt} other {detta}}", "user_purchase_settings": "Köp", "user_purchase_settings_description": "Hantera dina köp", - "user_usage_detail": "", + "user_role_set": "Sätt {user} som {role}", + "user_usage_detail": "Användaranvändningsdetaljer", "user_usage_stats": "Kontoinformation - statistik", "user_usage_stats_description": "Se statistik - kontoanvändande", "username": "Användarnamn", @@ -1193,8 +1345,12 @@ "variables": "Variabler", "version": "Version", "version_announcement_closing": "Din vän, Alex", + "version_announcement_message": "Hej där! En ny version av Immich är tillgänglig. Ta dig tid att läsa versionsfakta för att säkerställa att dina inställningar är uppdaterade för att förhindra eventuella felkonfigurationer, särskilt om du använder WatchTower eller någon mekanism som hanterar uppdatering av din Immich instans automatiskt.", + "version_history": "Versionshistorik", + "version_history_item": "Version {version} installerad {date}", "video": "Video", - "video_hover_setting_description": "", + "video_hover_setting": "Spela upp videotumnagel när muspekaren är över den", + "video_hover_setting_description": "Spela upp videotumnagel när muspekaren är över den. Även när den är deaktiverad kan uppspelning startas när muspekaren är över play-ikonen.", "videos": "Videor", "videos_count": "{count, plural, one {# Video} other {# Videor}}", "view": "Visa", @@ -1202,9 +1358,13 @@ "view_all": "Visa alla", "view_all_users": "Visa alla användare", "view_in_timeline": "Visa i tidslinjen", + "view_link": "Visa länk", "view_links": "Visa länkar", + "view_name": "Visa", "view_next_asset": "Visa nästa objekt", "view_previous_asset": "Visa föregående objekt", + "view_stack": "Visa Stapel", + "visibility_changed": "Synlighet ändrad för {count, plural, one {# person} other {# personer}}", "waiting": "Väntar", "warning": "Varning", "week": "Vecka", @@ -1215,4 +1375,4 @@ "yes": "Ja", "you_dont_have_any_shared_links": "Du har inga delade länkar", "zoom_image": "Zooma bild" -} +} \ No newline at end of file diff --git a/i18n/ta.json b/i18n/ta.json index c3d13dbdf0..2c58867226 100644 --- a/i18n/ta.json +++ b/i18n/ta.json @@ -297,7 +297,7 @@ "transcoding_max_b_frames": "அதிகபட்ச பி-பிரேம்கள்", "transcoding_max_b_frames_description": "அதிக மதிப்புகள் சுருக்க செயல்திறனை மேம்படுத்துகின்றன, ஆனால் குறியாக்கத்தை மெதுவாக்குகின்றன. பழைய சாதனங்களில் வன்பொருள் முடுக்கம் உடன் பொருந்தாது. 0 பி -பிரேம்களை முடக்குகிறது, அதே நேரத்தில் -1 இந்த மதிப்பை தானாக அமைக்கிறது.", "transcoding_max_bitrate": "அதிகபட்ச பிட்ரேட்", - "transcoding_max_bitrate_description": "அதிகபட்ச பிட்ரேட்டை அமைப்பது கோப்பு அளவுகளை ஒரு சிறிய செலவில் தரத்திற்கு கணிக்கக்கூடியதாக மாற்றும். 720p இல், வழக்கமான மதிப்புகள் VP9 அல்லது HEVC க்கு 2600K அல்லது H.264 க்கு 4500K ஆகும். 0 என அமைக்கப்பட்டால் முடக்கப்பட்டது.", + "transcoding_max_bitrate_description": "அதிகபட்ச பிட்ரேட்டை அமைப்பது கோப்பு அளவுகளை ஒரு சிறிய செலவில் தரத்திற்கு கணிக்கக்கூடியதாக மாற்றும். 720p இல், வழக்கமான மதிப்புகள் VP9 அல்லது HEVC க்கு 2600 kbit/s அல்லது H.264 க்கு 4500 kbit/s ஆகும். 0 என அமைக்கப்பட்டால் முடக்கப்பட்டது.", "transcoding_max_keyframe_interval": "அதிகபட்ச கீஃப்ரேம் இடைவெளி", "transcoding_max_keyframe_interval_description": "கீஃப்ரேம்களுக்கு இடையில் அதிகபட்ச பிரேம் தூரத்தை அமைக்கிறது. குறைந்த மதிப்புகள் சுருக்க செயல்திறனை மோசமாக்குகின்றன, ஆனால் தேடல் நேரங்களை மேம்படுத்துகின்றன, மேலும் வேகமான இயக்கத்துடன் காட்சிகளில் தரத்தை மேம்படுத்தலாம். 0 இந்த மதிப்பை தானாக அமைக்கிறது.", "transcoding_optimal_description": "இலக்கு தீர்மானத்தை விட உயர்ந்த வீடியோக்கள் அல்லது ஏற்றுக்கொள்ளப்பட்ட வடிவத்தில் இல்லை", @@ -1339,4 +1339,4 @@ "yes": "ஆம்", "you_dont_have_any_shared_links": "உங்களிடம் பகிரப்பட்ட இணைப்புகள் எதுவும் இல்லை", "zoom_image": "பெரிதாக்க படம்" -} +} \ No newline at end of file diff --git a/i18n/te.json b/i18n/te.json index 3f0f6ff546..5fc4300bb7 100644 --- a/i18n/te.json +++ b/i18n/te.json @@ -23,6 +23,7 @@ "add_to": "జోడించండి...", "add_to_album": "ఆల్బమ్‌కు జోడించండి", "add_to_shared_album": "భాగస్వామ్య ఆల్బమ్‌కు జోడించండి", + "add_url": "URLని జోడించండి", "added_to_archive": "ఆర్కైవ్‌కి జోడించబడింది", "added_to_favorites": "ఇష్టమైన వాటికి జోడించబడింది", "added_to_favorites_count": "ఇష్టమైన వాటికి {count, number} జోడించబడింది", @@ -213,6 +214,7 @@ "notifications": "నోటిఫికేషన్‌లు", "notifications_setting_description": "నోటిఫికేషన్‌లను నిర్వహించండి", "oauth": "OAuth", + "search_by_description": "వివరణ ద్వారా శోధించండి", "unsaved_change": "సేవ్ చేయని మార్పు", "unselect_all": "ఎంచుకున్నవన్నీ తొలగించు", "unselect_all_duplicates": "అన్ని నకిలీల ఎంపికను తీసివేయండి", diff --git a/i18n/th.json b/i18n/th.json index 8843db3850..f86ea9cd55 100644 --- a/i18n/th.json +++ b/i18n/th.json @@ -1,35 +1,35 @@ { "about": "เกี่ยวกับ", - "account": "บัญชี", + "account": "บัญชีผู้ใช้", "account_settings": "การตั้งค่าบัญชี", "acknowledge": "รับทราบ", - "action": "การดำเนินการ", + "action": "ดำเนินการ", "actions": "การดำเนินการ", "active": "ใช้งานอยู่", "activity": "กิจกรรม", "activity_changed": "กิจกรรม{enabled, select, true {เปิด} other {ปิด}}อยู่", "add": "เพิ่ม", - "add_a_description": "เพื่มรายละเอียด", + "add_a_description": "เพิ่มรายละเอียด", "add_a_location": "เพิ่มตำแหน่ง", "add_a_name": "เพิ่มชื่อ", "add_a_title": "เพิ่มหัวข้อ", "add_exclusion_pattern": "เพิ่มข้อยกเว้น", - "add_import_path": "เพิ่มพาธนำเข้า", + "add_import_path": "เพิ่มเส้นทางนำเข้า", "add_location": "เพิ่มตำแหน่ง", "add_more_users": "เพิ่มผู้ใช้งาน", - "add_partner": "เพิ่มคู่หู", - "add_path": "เพิ่มพาธ", + "add_partner": "เพิ่มพันธมิตร", + "add_path": "เพิ่มพาทที่ตั้ง", "add_photos": "เพิ่มรูปภาพ", - "add_to": "เพิ่มเข้า...", - "add_to_album": "เพิ่มเข้าอัลบั้ม", - "add_to_shared_album": "เพิ่มลงในอัลบั้มที่แชร์กัน", + "add_to": "เพิ่มไปยัง …", + "add_to_album": "เพิ่มไปอัลบั้ม", + "add_to_shared_album": "เพิ่มไปยังอัลบั้มที่แชร์กัน", "add_url": "เพิ่ม URL", - "added_to_archive": "เพิ่มเข้าที่เก็บถาวร", + "added_to_archive": "เพิ่มไปยังที่จัดเก็บถาวร", "added_to_favorites": "เพิ่มเข้ารายการโปรด", "added_to_favorites_count": "{count, number} รูปถูกเพิ่มเข้ารายการโปรด", "admin": { "add_exclusion_pattern_description": "เพิ่มรูปแบบข้อยกเว้น รองรับการใช้ *, ** และ ? หากต้องการละเว้นไฟล์ทั้งหมดในไดเร็กทอรีที่ชื่อว่า \"Raw\" ให้ใช้ \"**/Raw/**\" ถ้าต้องการละเว้นไฟล์ทั้งหมดที่ลงท้ายด้วย \".tif\" ให้ใช้ \"**/*.tif\" ถ้าต้องการละเว้นพาธที่เริ่มจากไดเรกทอรีบนสุดให้ใช้ \"/พาธ/ที่ต้องการ/ละเว้น/**\"", - "asset_offline_description": "Immich", + "asset_offline_description": "ไฟล์ Asset ของไลบรารีภายนอกนี้ไม่พบในดิสก์แล้ว และถูกย้ายไปที่ถังขยะ หากไฟล์ถูกย้ายภายในไลบรารี โปรดตรวจสอบไทม์ไลน์ของคุณเพื่อหาแอสเซ็ตที่เกี่ยวข้องใหม่ หากต้องการกู้คืน Asset นี้ โปรดตรวจสอบให้แน่ใจว่า Immich สามารถเข้าถึงเส้นทางไฟล์ด้านล่างได้ และทำการสแกนไลบรารีอีกครั้ง", "authentication_settings": "การตั้งค่าการเข้าถึง", "authentication_settings_description": "จัดการรหัสผ่าน, OAuth, และตั้งค่าการเข้าถึงอื่นๆ", "authentication_settings_disable_all": "คุณแน่ใจว่าต้องการปิดวิธีการล็อกอินทั้งหมดหรือไม่? ล็อกอินจะถูกปิดทั้งหมด", @@ -38,14 +38,14 @@ "backup_database": "สำรองฐานข้อมูล", "backup_database_enable_description": "เปิดใช้งานการสำรองฐานข้อมูล", "backup_keep_last_amount": "จำนวนข้อมูลสำรองก่อนหน้าที่ต้องเก็บไว้", - "backup_settings": "ตั้งค่ารการสำรองข้อมูล", + "backup_settings": "ตั้งค่าการสำรองข้อมูล", "backup_settings_description": "จัดการการตั้งค่าการสำรองฐานข้อมูล", "check_all": "ตรวจสอบทั้งหมด", "cleared_jobs": "เคลียร์งานสำหรับ: {job}", - "config_set_by_file": "ปัจจุบันการกำหนดค่าถูกตั้งค่าโดยไฟล์กำหนดค่า", + "config_set_by_file": "การตั้งค่าคอนฟิกกำลังถูกกำหนดโดยไฟล์คอนฟิก", "confirm_delete_library": "คุณแน่ใจว่าอยากลบคลังภาพ {library} หรือไม่?", "confirm_delete_library_assets": "คุณแน่ใจว่าอยากลบคลังภาพนี้หรือไม่? สี่อทั้งหมด {count, plural, one {# สื่อ} other {all # สื่อ}} สี่อในคลังจะถูกลบออกจาก Immich โดยถาวร ไฟล์จะยังคงอยู่บนดิสก์", - "confirm_email_below": "เพื่อยืนยัน พิมพ์ \"{email}\" ข้างล่าง", + "confirm_email_below": "โปรดยืนยัน โดยการพิมพ์ \"{email}\" ข้างล่าง", "confirm_reprocess_all_faces": "คุณแน่ใจว่าคุณต้องการประมวลผลใบหน้าทั้งหมดใหม่? ชื่อคนจะถูกลบไปด้วย", "confirm_user_password_reset": "คุณแน่ใจว่าต้องการรีเซ็ตรหัสผ่านของ {user} หรือไม่?", "create_job": "สร้างงาน", @@ -68,7 +68,8 @@ "image_prefer_embedded_preview": "ใช้พรีวิวแบบฝังตัว", "image_prefer_embedded_preview_setting_description": "ใช้พรีวิวฝังตัวในรูปภาพ RAW ในการวิเคราะห์รูปภาพถ้ามี แต่คุณภาพรูปภาพขึ้นอยู่กับกล้อง และอาจจะมีสิ่งตกค้างจากการย่อขนาดไฟล์", "image_prefer_wide_gamut": "ใช้ช่วงสีกว้าง", - "image_prefer_wide_gamut_setting_description": "ใช้การแสดงผลแบบ P3 สําหรับภาพตัวอย่าง คงความเข้มและความกว้างขอบเขตสี แต่ภาพอาจดูแตกต่างกันในอุปกรณ์เก่าที่มีเว็บเบราว์เซอร์รุ่นเก่า ภาพ sRGB จะถูกเก็บในรูปแบบ sRGB เพื่อลดการเคลื่อนของสี", + "image_prefer_wide_gamut_setting_description": "ใช้ Display P3 สำหรับภาพตัวอย่าง (thumbnails) เพื่อรักษาความสดใสของภาพที่มีช่วงสีที่กว้างขึ้น อย่างไรก็ตาม ภาพอาจแสดงผลแตกต่างกันบนอุปกรณ์เก่าที่ใช้เว็บเบราว์เซอร์เวอร์ชันเก่า สำหรับภาพที่อยู่ใน sRGB จะยังคงใช้ sRGB ต่อไปเพื่อหลีกเลี่ยงการเปลี่ยนแปลงของสี", + "image_preview_description": "ภาพขนาดปานกลางที่ถูกลบข้อมูลเมตา ใช้สำหรับการดูแอสเซ็ตเดี่ยวและสำหรับการเรียนรู้ของเครื่อง (Machine Learning)", "image_preview_quality_description": "คุณภาพการแสดงตัวอย่างตั้งแต่ 1-100 ยิ่งสูงยิ่งดี แต่จะทำให้ไฟล์มีขนาดใหญ่ขึ้นและอาจทำให้แอปตอบสนองช้าลง การตั้งค่าต่ำอาจส่งผลต่อคุณภาพ Machine Learning", "image_preview_title": "ตั้งค่าพรีวิว", "image_quality": "คุณภาพ", @@ -76,6 +77,7 @@ "image_resolution_description": "ความละเอียดสูกว่าสามารถเก็บรายละเอียดได้มากกว่าแต่ใช้เวลา encode นานกว่า ไฟล์ใหญ่กว่า และลดความตอบสนองของแอป", "image_settings": "การตั้งค่ารูปภาพ", "image_settings_description": "จัดการคุณภาพและความคมชัดของภาพที่สร้างขึ้น", + "image_thumbnail_description": "รูปขนาดย่อที่มีการลบข้อมูลเมตาด้าต้า ใช้เมื่อดูภาพถ่ายในกลุ่ม เช่น ในไทม์ไลน์หลัก", "image_thumbnail_quality_description": "คุณภาพของภาพขนาดย่อตั้งแต่ 1-100 ยิ่งสูงยิ่งดี แต่จะทำให้ไฟล์มีขนาดใหญ่ขึ้นและอาจทำให้แอปตอบสนองช้าลง", "image_thumbnail_title": "ตั้งค่า Thumbnail", "job_concurrency": "{job} งานพร้อมกัน", @@ -155,7 +157,7 @@ "migration_job_description": "ย้ายภาพตัวอย่างสื่อและใบหน้าไปยังโครงสร้างโฟลเดอร์ล่าสุด", "no_paths_added": "ไม่ได้เพิ่มพาธ", "no_pattern_added": "ไม่ได้เพิ่มรูปแบบ", - "note_apply_storage_label_previous_assets": "หมายเหตุ: หากจะแปะฉลากจัดเก็บใส่สื่อที่อัพโหลดก่อนหน้านี้ ให้", + "note_apply_storage_label_previous_assets": "หากต้องการใช้ Storage Label กับไฟล์ที่อัปโหลดก่อนหน้านี้ ให้รันคำสั่งนี้", "note_cannot_be_changed_later": "หมายเหตุ: ไม่สามารถเปลี่ยนภายหลังได้!", "note_unlimited_quota": "หมายเหตุ: ใส่เลข 0 สําหรับโควต้าไม่จํากัด", "notification_email_from_address": "จากที่อยู่", @@ -193,8 +195,8 @@ "oauth_settings_description": "จัดการการตั้งค่าล็อกอินผ่าน OAuth", "oauth_settings_more_details": "สำหรับรายละเอียดเพิ่มเติม ให้อ้างถึงเอกสาร", "oauth_signing_algorithm": "อัลกอริทึมการลงนาม", - "oauth_storage_label_claim": "สิทธิ์ที่ใช้อ้างถึงฉลากการจัดเก็บ", - "oauth_storage_label_claim_description": "ตั้งฉลากการจัดเก็บของผู้ใช้งานตามสิทธิ์ที่ใช้อ้างถึงโดยอัตโนมัติ", + "oauth_storage_label_claim": "สิทธิ์ที่ใช้อ้างถึงป้ายกำกับการจัดเก็บ", + "oauth_storage_label_claim_description": "ตั้งป้ายกำกับการจัดเก็บของผู้ใช้งานตามสิทธิ์ที่ใช้อ้างถึงโดยอัตโนมัติ", "oauth_storage_quota_claim": "สิทธิ์ที่ใช้อ้างถึงโควต้าพื้นที่จัดเก็บ", "oauth_storage_quota_claim_description": "ตั้งโควต้าพื้นที่จัดเก็บของผู้ใช้งานตามสิทธิ์ที่ใช้อ้างถึงโดยอัตโนมัติ", "oauth_storage_quota_default": "โควต้าพื้นที่เก็บข้อมูลเริ่มต้น (GiB)", @@ -205,6 +207,7 @@ "password_settings": "ล็อกอินผ่านรหัสผ่าน", "password_settings_description": "จัดการการตั้งค่าของการล็อกอินผ่านรหัสผ่าน", "paths_validated_successfully": "เส้นทางทั้งหมดถูกตรวจสอบสำเร็จแล้ว", + "person_cleanup_job": "การทำความสะอาด", "quota_size_gib": "โควตา (GiB)", "refreshing_all_libraries": "รีเฟรชคลังภาพทั้งหมด", "registration": "ลงทะเบียนผู้จัดการ", @@ -221,6 +224,7 @@ "server_external_domain_settings": "โดเมนภายนอก", "server_external_domain_settings_description": "โดเมนสำหรับลิงก์แชร์สาธารณะ แบบมี http(s)://", "server_public_users": "ผู้ใช้สาธารณะ", + "server_public_users_description": "ผู้ใช้ทั้งหมด (ชื่อและอีเมล) จะแสดงรายการเมื่อเพิ่มผู้ใช้ไปยังอัลบั้มที่แชร์ เมื่อปิดใช้งาน รายชื่อผู้ใช้จะพร้อมใช้งานสำหรับผู้ใช้ที่เป็นผู้ดูแลระบบเท่านั้น", "server_settings": "การตั้งค่าเซิร์ฟเวอร์", "server_settings_description": "จัดการการตั้งค่าเซิร์ฟเวอร์", "server_welcome_message": "ข้อความต้อนรับ", @@ -235,13 +239,27 @@ "storage_template_hash_verification_enabled": "ตรวจสอบ hash ไม่ผ่าน", "storage_template_hash_verification_enabled_description": "เปิดใช้งานการตรวจสอบ hash ห้ามปิดใช้งานเว้นแต่คุณจะเข้าใจผลกระทบ", "storage_template_migration": "การย้ายเทมเพลตที่เก็บข้อมูล", - "storage_template_migration_description": "ใช้{template}ปัจจุบันกับสื่อที่อัพโหลดก่อนหน้านี้", - "storage_template_migration_job": "", + "storage_template_migration_description": "ใช้{template}ปัจจุบันกับสื่อที่อัปโหลดก่อนหน้านี้", + "storage_template_migration_info": "การเปลี่ยนแปลงเท็มเพลตจะมีผลกับแอสเซ็ตใหม่เท่านั้น หากต้องการนำเทมเพลตไปใช้กับ Asset ที่อัปโหลดก่อนหน้านี้ ให้รัน {job}.", + "storage_template_migration_job": "เทมเพลตการ Migration ข้อมูล", + "storage_template_more_details": "สำหรับรายละเอียดเพิ่มเติมเกี่ยวกับฟีเจอร์นี้ โปรดดูที่ Storage Template และ ผลกระทบ", + "storage_template_onboarding_description": "เมื่อเปิดใช้งาน ฟีเจอร์นี้จะจัดระเบียบไฟล์โดยอัตโนมัติตามเทมเพลตที่ผู้ใช้กำหนด เนื่องจากปัญหาด้านความเสถียร ฟีเจอร์นี้จึงถูกปิดใช้งานเป็นค่าเริ่มต้น สำหรับข้อมูลเพิ่มเติม โปรดดูที่ เอกสารประกอบ", "storage_template_path_length": "ขีดจำกัดของความยาวพาธโดยประมาณ: {length, number}/{limit, number}", "storage_template_settings": "เทมเพลตการจัดเก็บข้อมูล", "storage_template_settings_description": "จัดการโครงสร้างโฟลเดอร์และชื่อไฟล์ที่อัปโหลด", + "storage_template_user_label": "{label} ป้ายกำกับพื้นที่จักเก็บข้อมูล", "system_settings": "การตั้งค่าระบบ", + "tag_cleanup_job": "เคลียร์ Tags", + "template_email_available_tags": "คุณสามารถใช้ตัวแปรต่อไปนี้ในเทมเพลตของคุณได้: {tags}", + "template_email_if_empty": "หากเทมเพลตว่างเปล่า ระบบจะใช้อีเมลเริ่มต้น", + "template_email_invite_album": "เทมเพลตเชิญเข้าอัลบั้ม", "template_email_preview": "ตัวอย่าง", + "template_email_settings": "อีเมลเท็มเพลต", + "template_email_settings_description": "ปรับแต่งรูปแบบการแจ้งเตือนอีเมล", + "template_email_update_album": "อัปเดตเทมเพลตอัลบั้ม", + "template_email_welcome": "เทมเพลตสำหรับอีเมลต้อนรับ", + "template_settings": "เทมเพลตการแจ้งเตือน", + "template_settings_description": "ปรับแต่งเทมเพลตแจ้งเตือน", "theme_custom_css_settings": "CSS กําหนดเอง", "theme_custom_css_settings_description": "Cascading Style Sheets ช่วยให้ปรับแต่งเค้าโครง Immich ได้", "theme_settings": "การตั้งค่าธีม", @@ -250,54 +268,62 @@ "thumbnail_generation_job": "สร้างภาพตัวอย่าง", "thumbnail_generation_job_description": "สร้างภาพตัวอย่างขนาดใหญ่ ขนาดเล็กและแบบเบลอ สําหรับแต่ละสื่อและบุคคล", "transcoding_acceleration_api": "API เร่งความเร็วแปลงสื่อ", - "transcoding_acceleration_api_description": "", + "transcoding_acceleration_api_description": "API ที่จะโต้ตอบกับอุปกรณ์ของคุณเพื่อเร่งการแปลงรหัส การตั้งค่านี้คือ 'ความพยายามที่ดีที่สุด': มันจะย้อนกลับไปยังการการแปลงรหัสของซอฟต์แวร์เมื่อเกิดความล้มเหลว VP9 อาจทำงานหรือไม่ทำงานก็ได้ ขึ้นอยู่กับฮาร์ดแวร์ของคุณ", "transcoding_acceleration_nvenc": "NVENC (ต้องมีการ์ดจอ NVIDIA)", "transcoding_acceleration_qsv": "Quick Sync (ต้องมี Intel CPU รุ่นที่ 7 หรือใหม่กว่า)", "transcoding_acceleration_rkmpp": "RKMPP (สำหรับ Rockchip SOCs เท่านั้น)", "transcoding_acceleration_vaapi": "VAAPI", "transcoding_accepted_audio_codecs": "แบบไฟล์เสียงที่ยอมรับ", "transcoding_accepted_audio_codecs_description": "เลือกแบบไฟล์เสียงที่จะไม่ถูกแปลงใหม่ ใช้สําหรับกฏการแปลงแบบไฟล์", + "transcoding_accepted_containers": "Containers ที่ยอมรับ", + "transcoding_accepted_containers_description": "เลือกรูปแบบคอนเทนเนอร์ที่ไม่จำเป็นต้องรีมิกซ์เป็น MP4 ใช้สำหรับนโยบายการแปลงบางอย่างเท่านั้น", "transcoding_accepted_video_codecs": "แบบไฟล์วิดีโอที่ยอมรับ", "transcoding_accepted_video_codecs_description": "เลือกแบบไฟล์วิดีโอที่จะไม่ถูกแปลงใหม่ ใช้สําหรับกฎการแปลงแบบไฟล์", "transcoding_advanced_options_description": "ตัวเลือกที่ผู้ใช้งานส่วนใหญ่ไม่จำเป็นต้องเปลี่ยน", "transcoding_audio_codec": "แบบไฟล์เสียง", "transcoding_audio_codec_description": "Opus ให้คุณภาพสูงสุด แต่อาจจะเข้ากันไม่ได้กับอุปกรณ์เก่าหรือซอฟต์แวร์เก่า", "transcoding_bitrate_description": "วิดีโอมีค่า bitrate สูงกว่าค่าสูงสุดหรือไฟล์วิดีโอไม่รองรับ", + "transcoding_codecs_learn_more": "หากต้องการเรียนรู้เพิ่มเติมเกี่ยวกับคำศัพท์ที่ใช้ที่นี่ โปรดดูเอกสารประกอบของ FFmpeg สำหรับ H.264 codec, HEVC codec และ VP9 codec", "transcoding_constant_quality_mode": "โหมดคุณภาพคงที่", "transcoding_constant_quality_mode_description": "ICQ ดีกว่า CQP แต่อุปกรณ์บางตัวอาจจะไม่รองรับโหมดนี้ การตั้งค่าตัวนี้จะเลือกโหมดที่ระบุไว้เมื่อใช้การแปลงคุณภาพไฟล์ ไม่สนใจ NVENC เพราะไม่รองรับ ICQ", "transcoding_constant_rate_factor": "ตัวแปรค่าคงที่ (-crf)", "transcoding_constant_rate_factor_description": "คุณภาพของวิดีโอ ค่าโดยปกติคือ 23 สําหรับ H.264, 28 สําหรับ HEVC, 31 สําหรับ VP9 และ 35 สําหรับ AV1 ค่าต่ำกว่าคุณภาพจะดีกว่า แต่ไฟล์จะขนาดใหญ่กว่า", "transcoding_disabled_description": "ไม่แปลงไฟล์วิดีโอเลย อาจเล่นวิดีโอในเครื่องเล่นบางตัวไม่ได้", + "transcoding_encoding_options": "ตัวเลือกการเข้ารหัส", + "transcoding_encoding_options_description": "ตั้งค่า codecs, ความละเอียด, คุณภาพ (และตัวเลือกอื่นๆ สำหรับวิดีโอที่เข้ารหัส (encoded videos)", "transcoding_hardware_acceleration": "การเร่งความเร็วด้วยฮาร์ดแวร์", "transcoding_hardware_acceleration_description": "การทดลอง เร็วกว่ามาก แต่จะมีคุณภาพต่ำกว่าที่บิตเรตเท่ากัน", "transcoding_hardware_decoding": "การถอดรหัสด้วยฮาร์ดแวร์", - "transcoding_hardware_decoding_setting_description": "", + "transcoding_hardware_decoding_setting_description": "เปิดใช้งานการเร่งความเร็วแบบทั้งหมด แทนการเร่งความเร็วการเข้ารหัสเพียงอย่างเดียว อาจใช้ไม่ได้กับวิดีโอทั้งหมด", "transcoding_hevc_codec": "แบบไฟล์ HEVC", - "transcoding_max_b_frames": "", - "transcoding_max_b_frames_description": "", + "transcoding_max_b_frames": "B-frames สูงสุด", + "transcoding_max_b_frames_description": "ค่าที่สูงขึ้นจะช่วยเพิ่มประสิทธิภาพในการบีบอัด แต่จะทำให้การเข้ารหัสช้าลง อาจไม่สามารถใช้งานร่วมกับการเร่งความเร็วฮาร์ดแวร์บนอุปกรณ์เก่าได้ ค่าที่เป็น 0 จะปิดการใช้งาน B-frame ในขณะที่ค่า -1 จะตั้งค่าค่านี้โดยอัตโนมัติ", "transcoding_max_bitrate": "bitrate สูงสุด", - "transcoding_max_bitrate_description": "การตั้งค่า bitrate สูงสุดจะสามารถคาดเดาขนาดไฟล์ได้มากขึ้นโดยไม่กระทบคุณภาพ สำหรับความคมชัด 720p ค่าทั่วไปคือ 2600k สําหรับ VP9 หรือ HEVC, 4500k สําหรับ H.264 ปิดการตั้งค่าเมี่อตั้งค่าเป็น 0", - "transcoding_max_keyframe_interval": "", - "transcoding_max_keyframe_interval_description": "", + "transcoding_max_bitrate_description": "การตั้งค่า bitrate สูงสุดจะสามารถคาดเดาขนาดไฟล์ได้มากขึ้นโดยไม่กระทบคุณภาพ สำหรับความคมชัด 720p ค่าทั่วไปคือ 2600 kbit/s สําหรับ VP9 หรือ HEVC, 4500 kbit/s สําหรับ H.264 ปิดการตั้งค่าเมี่อตั้งค่าเป็น 0", + "transcoding_max_keyframe_interval": "ช่วงเวลาสูงสุดระหว่างกราฟฟ์เคลื่อนไหว", + "transcoding_max_keyframe_interval_description": "ตั้งค่าระยะห่างสูงสุดระหว่างคีย์เฟรม (keyframes) ค่าที่ต่ำลงจะทำให้ประสิทธิภาพการบีบอัดแย่ลง แต่จะช่วยปรับปรุงเวลาในการค้นหาภาพ (seek times) และอาจช่วยปรับปรุงคุณภาพในฉากที่มีการเคลื่อนไหวเร็ว ค่า 0 จะตั้งค่านี้โดยอัตโนมัติ", "transcoding_optimal_description": "วีดิโอมีความคมชัดสูงกว่าเป้าหมายหรืออยู่ในรูปแบบที่รับไม่ได้", - "transcoding_preferred_hardware_device": "", - "transcoding_preferred_hardware_device_description": "", + "transcoding_policy": "นโยบายการเข้ารหัส", + "transcoding_policy_description": "ตั้งค่าเวลาที่วิดีโอจะถูกแปลงรหัส", + "transcoding_preferred_hardware_device": "อุปกรณ์ฮาร์ดแวร์ที่ต้องการ", + "transcoding_preferred_hardware_device_description": "ใช้ได้กับ VAAPI และ QSV เท่านั้น ตั้งค่าโหนด dri ที่ใช้สำหรับทรานส์โค้ดฮาร์ดแวร์", "transcoding_preset_preset": "พรีเซ็ต (-preset)", - "transcoding_preset_preset_description": "", - "transcoding_reference_frames": "", - "transcoding_reference_frames_description": "", - "transcoding_required_description": "", + "transcoding_preset_preset_description": "ความเร็วในการบีบอัด พรีเซ็ตที่ช้ากว่าจะสร้างไฟล์ที่มีขนาดเล็กลงและเพิ่มคุณภาพเมื่อกำหนดเป้าหมายที่อัตราบิตเรตที่กำหนด VP9 จะไม่สนใจความเร็วที่สูงกว่า 'เร็วกว่า'", + "transcoding_reference_frames": "frames อ้างอิง", + "transcoding_reference_frames_description": "จำนวนเฟรมที่จะอ้างอิงเมื่อบีบอัดเฟรมที่กำหนด ค่าที่สูงขึ้นจะช่วยเพิ่มประสิทธิภาพในการบีบอัด แต่จะทำให้การเข้ารหัสช้าลง ค่า 0 จะตั้งค่านี้โดยอัตโนมัติ", + "transcoding_required_description": "เฉพาะวิดีโอที่ไม่อยู่ในรูปแบบที่ยอมรับเท่านั้น", "transcoding_settings": "การตั้งค่าการแปลงไฟล์วิดีโอ", "transcoding_settings_description": "จัดการข้อมูลความคมชัดและแบบไฟล์วิดีโอ", "transcoding_target_resolution": "เป้าหมายความคมชัด", "transcoding_target_resolution_description": "ความคมชัดที่สูงกว่าจะเก็บรายละเอียดดีกว่าแต่ใช้เวลาแปลงไฟล์นานกว่า ขนาดไฟล์ใหญ่กว่า และลดการตอบสนองของแอป", - "transcoding_temporal_aq": "", + "transcoding_temporal_aq": "AQ ชั่วคราว", "transcoding_temporal_aq_description": "เฉพาะ NVENC เท่านั้น เพิ่มคุณภาพของฉากที่มีรายละเอียดสูงและการเคลื่อนไหวต่ำ อาจไม่รองรับอุปกรณ์ที่เก่ากว่า", "transcoding_threads": "เธรด", "transcoding_threads_description": "ค่ายิ่งเยอะจะแปลงไฟล์เร็วกว่า แต่จะเหลือพื้นที่ให้เซิร์ฟเวอร์ประมวลผลงานอื่นน้อยลงเมื่อทํางานนี้ ค่านี้ไม่ควรมากกว่าจํานวน CPU core จะประมวลผลเต็มที่เมื่อตั้งเป็น 0", "transcoding_tone_mapping": "การฉายโทนสี", - "transcoding_tone_mapping_description": "", + "transcoding_tone_mapping_description": "พยายามรักษารูปแบบวิดีโอ HDR เมื่อแปลงเป็น SDR อัลกอริทึมแต่ละตัวจะปรับสี,รายละเอียด และความสว่างแตกต่างกัน Hable จะรักษารายละเอียด Mobius จะรักษาสี และ Reinhard จะรักษาความสว่าง", "transcoding_transcode_policy": "กฎการแปลงไฟล์", + "transcoding_transcode_policy_description": "นโยบายเกี่ยวกับเวลาที่วิดีโอจะต้องได้รับการแปลงรหัส วิดีโอ HDR จะได้รับการแปลงรหัสเสมอ (ยกเว้นในกรณีที่ปิดใช้งานการแปลงรหัส)", "transcoding_two_pass_encoding": "การแปลงไฟล์สองรอบ", "transcoding_two_pass_encoding_setting_description": "การแปลงไฟล์สองรอบจะช่วยให้ได้วิดีโอที่ดีขึ้น เมื่อเปิดใช้งาน bitrate สูงสุด (จำเป็นสำหรับไฟล์ H.264 และ HEVC) โหมดนี้จะใช้ช่วง bitrate ที่ขึ้นอยู่กับค่า bitrate สูงสุดและไม่สนใจ CRF สำหรับ VP9 สามารถใช้ค่า CRF ได้ถ้าปิดใช้งาน bitrate สูงสุด", "transcoding_video_codec": "แบบไฟล์วิดีโอ", @@ -307,14 +333,24 @@ "trash_number_of_days_description": "จํานวนวันที่เก็บสื่อไว้ในถังขยะก่อนที่จะลบถาวร", "trash_settings": "การตั้งค่าถังขยะ", "trash_settings_description": "จัดการการตั้งค่าถังขยะ", + "untracked_files": "ไฟล์ที่ไม่ได้ติดตาม", + "untracked_files_description": "แอปพลิเคชันจะไม่ติดตามไฟล์เหล่านี้ อาจเป็นผลลัพธ์จากการย้ายที่ล้มเหลว การอัปโหลดที่หยุดชะงัก หรือการถูกทิ้งไว้เนื่องจากจุดบกพร่อง", "user_cleanup_job": "ล้างผู้ใช้", "user_delete_delay": "บัญชีและสื่อของ {user} จะถูกตั้งเวลาสำหรับการลบถาวรใน {delay, plural, one {# วัน} other {# วัน}}", "user_delete_delay_settings": "ลบการถ่วงเวลา", "user_delete_delay_settings_description": "จํานวนวันหลังจากที่เอาออกเพื่อลบบัญชีผู้ใช้และสื่อถาวร งานลบบัญชีผู้ใช้ทํางานทุกเที่ยงคืนเพื่อตรวจสอบผู้ใช้ที่พร้อมที่จะถูกลบข้อมูลแล้ว การตั้งค่าครั้งนี้จะมีผลครั้งต่อไป", "user_delete_immediately": "บัญชีและสื่อของ {user} จะอยู่ในคิวสำหรับการลบถาวร โดยทันที", + "user_delete_immediately_checkbox": "คิวผู้ใช้และสื่อสำหรับการลบทันที", + "user_management": "การจัดการผู้ใช้", + "user_password_has_been_reset": "รหัสผ่านของผู้ใช้ถูกตั้งค่าใหม่แล้ว:", + "user_password_reset_description": "รหัสผ่านของผู้ใช้จะถูกตั้งค่าใหม่และส่งไปยังอีเมลที่ลงทะเบียน", + "user_restore_description": "บัญชีของ {user} จะได้รับการคืนค่า", + "user_restore_scheduled_removal": "กู้คืนผู้ใช้ - กำหนดการลบในวันที่ {date, date,long}", "user_settings": "การตั้งค่าผู้ใช้", "user_settings_description": "จัดการการตั้งค่าผู้ใช้", + "user_successfully_removed": "ลบผู้ใช้ {email} เรียบร้อยแล้ว", "version_check_enabled_description": "เช็ค GitHub เป็นระยะ ๆ เพื่อตรวจสอบรุ่นใหม่", + "version_check_implications": "การตรวจสอบเวอร์ชันใหม่จะต้องติดต่อกับ github.com เป็นระยะ", "version_check_settings": "ตรวจสอบรุ่น", "version_check_settings_description": "เปิด/ปิดการแจ้งเตือนรุ่นใหม่", "video_conversion_job_description": "แปลงไฟล์วิดีโอเพึ่อรองรับบราวเซอร์และเครื่องเล่นอื่น ๆ มากขึ้น" @@ -329,12 +365,20 @@ "album_added": "เพิ่มอัลบั้มแล้ว", "album_added_notification_setting_description": "แจ้งเตือนอีเมลเมื่อคุณถูกเพิ่มไปในอัลบั้มที่แชร์กัน", "album_cover_updated": "อัพเดทหน้าปกอัลบั้มแล้ว", - "album_info_updated": "อัพเดทข้อมูลอัลบั้มแล้ว", + "album_delete_confirmation": "คุณแน่ใจที่จะลบอัลบั้ม {album} นี้ ?", + "album_delete_confirmation_description": "หากแชร์อัลบั้มนี้ ผู้ใช้รายอื่นจะไม่สามารถเข้าถึงได้อีก", + "album_info_updated": "อัปเดทข้อมูลอัลบั้มแล้ว", + "album_leave": "ออกจากอัลบั้ม ?", + "album_leave_confirmation": "คุณต้องการออกจากอัลบั้ม {album} ใช่หรือไม่", "album_name": "ชื่ออัลบั้ม", "album_options": "ตัวเลือกอัลบั้ม", - "album_updated": "อัพเดทอัลบั้มแล้ว", + "album_remove_user": "ลบผู้ใช้ ?", + "album_remove_user_confirmation": "คุณต้องการที่จะลบผู้ใช้ {user} ?", + "album_share_no_users": "ดูเหมือนว่าคุณได้แชร์อัลบั้มนี้กับผู้ใช้ทั้งหมดแล้ว", + "album_updated": "อัปเดทอัลบั้มแล้ว", "album_updated_setting_description": "แจ้งเตือนอีเมลเมื่ออัลบั้มที่แชร์กันมีสื่อใหม่", "album_user_left": "ออกจาก {album}", + "album_user_removed": "ลบผู้ใช้ {user} แล้ว", "album_with_link_access": "อนุญาตให้ทุกคนที่มีลิงก์สามารถดูรูปภาพและผู้คนที่อยู่ในอัลบั้มนี้", "albums": "อัลบั้ม", "albums_count": "{count, plural, one {{count, number} อัลบั้ม} other {{count, number} อัลบั้ม}}", @@ -347,32 +391,60 @@ "allow_public_user_to_download": "อนุญาตให้ผู้ใช้สาธารณะดาวน์โหลดได้", "allow_public_user_to_upload": "อนุญาตให้ผู้ใช้สาธารณะอัปโหลดได้", "anti_clockwise": "ทวนเข็มนาฬิกา", - "api_key": "กุญแจ API", - "api_keys": "กุญแจ API", + "api_key": "API key", + "api_key_description": "ค่านี้จะแสดงเพียงครั้งเดียว โปรดคัดลอกก่อนปิดหน้าต่าง", + "api_key_empty": "ชื่อคีย์ API ของคุณไม่ควรว่างเปล่า", + "api_keys": "API คีย์", "app_settings": "การตั้งค่าแอป", "appears_in": "อยู่ใน", "archive": "เก็บถาวร", "archive_or_unarchive_photo": "เก็บ/ไม่เก็บภาพถาวร", + "archive_size": "ขนาดเก็บถาวร", + "archive_size_description": "ตั้งค่าขนาดสูงสุดสำหรับการดาวน์โหลด (GiB)", "are_these_the_same_person": "เป็นคนเดียวกันหรือไม่?", "are_you_sure_to_do_this": "คุณแน่ใจว่าต้องการทำสิ่งนี้หรือไม่?", "asset_added_to_album": "เพิ่มไปยังอัลบั้มแล้ว", - "asset_adding_to_album": "กำลังเพิ่มไปยังอัลบั้ม...", + "asset_adding_to_album": "กำลังเพิ่มไปยังอัลบั้ม…", + "asset_description_updated": "อัปเดตรายละเอียดสำเร็จ", + "asset_filename_is_offline": "สื่อ {filename} ออฟไลน์อยู่", + "asset_has_unassigned_faces": "สื่อไม่ได้ระบุใบหน้า", "asset_offline": "สื่อออฟไลน์", + "asset_offline_description": "ไม่พบทรัพยากรภายนอกนี้ในดิสก์อีกต่อไป โปรดติดต่อผู้ดูแลระบบ Immich ของคุณเพื่อขอความช่วยเหลือ", "asset_skipped": "ข้ามแล้ว", "asset_skipped_in_trash": "ในถังขยะ", "asset_uploaded": "อัปโหลดแล้ว", - "asset_uploading": "กำลังอัปโหลด...", + "asset_uploading": "กำลังอัปโหลด…", "assets": "สื่อ", + "assets_added_to_album_count": "เพิ่ม {count, plural, one {# asset} other {# assets}} ไปยังอัลบั้ม", + "assets_added_to_name_count": "เพิ่ม {count, plural, one {# asset} other {# assets}} ไปยัง {hasName, select, true {{name}} other {new album}}", + "assets_moved_to_trash_count": "ย้าย {count, plural, one {# asset} other {# assets}} ไปยังถังขยะแล้ว", + "assets_permanently_deleted_count": "ลบ {count, plural, one {# asset} other {# assets}} ทิ้งถาวร", + "assets_removed_count": "{count, plural, one {# asset} other {# assets}} ถูกลบแล้ว", + "assets_restore_confirmation": "คุณแน่ใจหรือไม่ว่าต้องการกู้คืนสื่อที่ทิ้งทั้งหมด? คุณไม่สามารถย้อนกลับการดำเนินการนี้ได้! โปรดทราบว่าสื่อออฟไลน์ใดๆ ไม่สามารถกู้คืนได้ด้วยวิธีนี้", + "assets_restored_count": "{count, plural, one {# asset} other {# assets}} คืนค่า", + "assets_trashed_count": "{count, plural, one {# asset} other {# assets}} ถูกลบ", + "assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} อยู่ในอัลบั้มอยู่แล้ว", "authorized_devices": "อุปกรณ์ที่ได้รับอนุญาต", "back": "กลับ", + "back_close_deselect": "ย้อนกลับ, ปิด, หรือยกเลิกการเลือก", "backward": "กลับหลัง", + "birthdate_saved": "บันทึกวันเกิดแล้ว", + "birthdate_set_description": "วันที่เกิดจะนำมาใช้ในการคำนวณอายุของบุคคลนี้ในขณะที่ถ่ายรูป", "blurred_background": "พื้นหลังแบบเบลอ", + "bugs_and_feature_requests": "รายงานข้อผิดพลาด & ข้อเสนอแนะ", + "build": "สร้าง", + "build_image": "สร้าง Image", + "bulk_delete_duplicates_confirmation": "คุณแน่ใจหรือไม่ว่าต้องการลบ {count, plural, one {# duplicate asset} other {# duplicate asset}} เป็นกลุ่ม การดำเนินการนี้จะเก็บสื่อที่ใหญ่ที่สุดของแต่ละกลุ่มและลบสื่อที่ซ้ำกันทั้งหมดอย่างถาวร คุณไม่สามารถย้อนกลับการดำเนินการนี้ได้!", + "bulk_keep_duplicates_confirmation": "คุณแน่ใจหรือไม่ว่าต้องการเก็บ {count, plural, one {# duplicate asset} other {# duplicate asset}} ไว้ การดำเนินการนี้จะแก้ไขกลุ่มที่ซ้ำกันทั้งหมดโดยไม่ต้องลบสิ่งใดเลย", + "bulk_trash_duplicates_confirmation": "คุณแน่ใจหรือไม่ว่าต้องการลบข้อมูลจำนวนมาก {count, plural, one {# duplicate asset} other {# duplicate asset}} การทำเช่นนี้จะเก็บสื่อที่ใหญ่ที่สุดของแต่ละกลุ่มและลบข้อมูลซ้ำอื่น ๆ ทั้งหมด", + "buy": "ซื้อ Immich", "camera": "กล้อง", "camera_brand": "ยี่ห้อกล้อง", "camera_model": "รุ่นกล้อง", "cancel": "ยกเลิก", "cancel_search": "ยกเลิกการค้นหา", "cannot_merge_people": "ไม่สามารถรวมกลุ่มคนได้", + "cannot_undo_this_action": "ไม่สามารถย้อนกลับได้", "cannot_update_the_description": "ไม่สามารถอัพเดทรายละเอียดได้", "change_date": "เปลี่ยนวันที่", "change_expiration_time": "เปลี่ยนเวลาหมดอายุ", @@ -380,27 +452,34 @@ "change_name": "เปลี่ยนชื่อ", "change_name_successfully": "เปลี่ยนชื่อเรียบร้อยแล้ว", "change_password": "เปลี่ยนรหัสผ่าน", - "change_your_password": "", - "changed_visibility_successfully": "", - "check_logs": "", + "change_password_description": "การเข้าสู่ระบบครั้งแรก จำเป็นจต้องเปลี่ยนรหัสผ่านของคุณเพื่อความปลอดภัย โปรดป้อนรหัสผ่านใหม่ด้านล่าง", + "change_your_password": "เปลี่ยนรหัสผ่านของคุณ", + "changed_visibility_successfully": "เปลี่ยนการมองเห็นเรียบร้อยแล้ว", + "check_all": "เลือกทั้งหมด", + "check_logs": "ตรวจสอบบันทึก", + "choose_matching_people_to_merge": "เลือกคนที่ตรงกันเพื่อรวมเข้าด้วยกัน", "city": "เมือง", "clear": "ล้าง", - "clear_all": "", - "clear_message": "", - "clear_value": "", + "clear_all": "ล้างทั้งหมด", + "clear_all_recent_searches": "ล้างประวัติการค้นหา", + "clear_message": "ล้างข้อความ", + "clear_value": "ล้างค่า", + "clockwise": "ตามเข็มนาฬิกา", "close": "ปิด", "collapse": "ย่อ", "collapse_all": "ย่อทั้งหมด", "color": "สี", - "color_theme": "", + "color_theme": "สีธีม", "comment_deleted": "ลบความคิดเห็นแล้ว", - "comment_options": "", + "comment_options": "ตัวเลือกความคิดเห็น", "comments_and_likes": "ความคิดเห็นและการถูกใจ", "comments_are_disabled": "ความคิดเห็นถูกปิดใช้งาน", "confirm": "ยืนยัน", "confirm_admin_password": "ยืนยันรหัสผ่านผู้ดูแลระบบ", + "confirm_delete_shared_link": "คุณต้องการที่จะลบลิงก์ที่แชร์ใช่หรือไม่ ?", + "confirm_keep_this_delete_others": "จะลบทั้งหมดในรายการ และยกเว้นสื่อนี้หรือไม่ คุณแน่ใจใช่ไหมที่ต้องการดำเนินการต่อ?", "confirm_password": "ยืนยันรหัสผ่าน", - "contain": "มี", + "contain": "มีอยู่", "context": "บริบท", "continue": "ต่อไป", "copied_image_to_clipboard": "คัดลอกภาพไปยังคลิปบอร์ดแล้ว", @@ -420,8 +499,12 @@ "create_library": "สร้างคลังภาพ", "create_link": "สร้างลิงก์", "create_link_to_share": "สร้างลิงก์เพื่อแชร์", + "create_link_to_share_description": "ผู้ที่มีลิงก์ สามารถดูรูปที่เลือกได้", "create_new_person": "สร้างคนใหม่", + "create_new_person_hint": "กำหนดสื่อที่เลือกให้กับคนใหม่", "create_new_user": "สร้างผู้ใช้งานใหม่", + "create_tag": "สร้างแท็กใหม่", + "create_tag_description": "สร้างแท็กใหม่ สำหรับแท็กที่ซ้อนกัน โปรดป้อนเส้นทางทั้งหมดของแท็ก รวมถึงเครื่องหมายทับ", "create_user": "สร้างผู้ใช้", "created": "สร้างแล้ว", "current_device": "อุปกรณ์ปัจจุบัน", @@ -431,33 +514,58 @@ "date_after": "วันที่หลังจาก", "date_and_time": "วันและเวลา", "date_before": "วันที่ก่อน", + "date_of_birth_saved": "บันทึกวันเกิดเรียบร้อยแล้ว", "date_range": "ช่วงวันที่", "day": "วัน", + "deduplicate_all": "รวมเข้าด้วยกันทั้งหมด", + "deduplication_criteria_1": "ขนาดไบต์ของรูปภาพ", + "deduplication_criteria_2": "จำนวนข้อมูล EXIF", + "deduplication_info": "ข้อมูลการขจัดข้อมูลซ้ำซ้อน", + "deduplication_info_description": "เลือกสื่อล่วงหน้าโดยอัตโนมัติและลบรายการซ้ำซ้อนจำนวนมาก เราจะดูที่:", "default_locale": "ภาษาท้องถิ่นปกติ", "default_locale_description": "ใช้รูปแบบวันที่และตัวเลขจากเบราว์เซอร์ของคุณ", "delete": "ลบออก", "delete_album": "ลบอัลบั้ม", + "delete_api_key_prompt": "คุณต้องการลบ API คีย์ นี้ใช่ไหม ?", + "delete_duplicates_confirmation": "คุณแน่ใจที่ต้องการลบรายการซ้ำอย่างถาวรใช่ไหม ?", "delete_key": "ลบกุญแจ", "delete_library": "ลบคลังภาพ", "delete_link": "ลบลิงก์", + "delete_others": "ลบผู้อื่น", "delete_shared_link": "ลบลิงก์ที่แชร์", + "delete_tag": "ลบแท็ก", + "delete_tag_confirmation_prompt": "คุณต้องการลบแท็ก {tagName} ใช่หรือไม่", "delete_user": "ลบผู้ใช้", "deleted_shared_link": "ลบลิงก์ที่แชร์แล้ว", + "deletes_missing_assets": "ลบสื่อที่หายไปออกจากดิสถ์", "description": "รายละเอียด", "details": "รายละเอียด", "direction": "เส้นทาง", + "disabled": "ปิดการใช้งาน", "disallow_edits": "ไม่อนุญาตให้แก้ไข", + "discord": "Discord", "discover": "ค้นพบ", "dismiss_all_errors": "ปฏิเสธข้อผิดพลาดทั้งหมด", "dismiss_error": "ปฏิเสธข้อผิดพลาด", - "display_options": "", - "display_order": "", - "display_original_photos": "", - "display_original_photos_setting_description": "เมื่อดูสื่อให้แสดงภาพต้นฉบับแทนภาพตัวอย่างเมื่อไฟล์สื่อเปิดได้บนเว็บ อาจทําให้แสดง ภาพได้ช้าลง", - "done": "เสร็จ", + "display_options": "ตัวเลือกการแสดง", + "display_order": "ลำดับการแสดงผล", + "display_original_photos": "แสดงภาพต้นฉบับ", + "display_original_photos_setting_description": "การตั้งค่าแสดงผลรูปภาพต้นฉบับ เมื่อเปิดรูปภาพ การตั้งค่านี้อาจจะทำให้การแสดงภาพได้ช้าลง", + "do_not_show_again": "ไม่แสดงข้อความนี้อีก", + "documentation": "เอกสาร", + "done": "ดำเนินการสำเร็จ", "download": "ดาวน์โหลด", + "download_include_embedded_motion_videos": "รวมวิดีโอที่ฝังอยู่ในภาพเคลื่อนไหว", + "download_include_embedded_motion_videos_description": "รวมวิดีโอที่ฝังอยู่ในภาพเคลื่อนไหวเมื่อดาวน์โหลดอัลบั้ม", + "download_settings": "การตั้งค่าการดาวน์โหลด", + "download_settings_description": "จัดการการตั้งค่าการดาวน์โหลด", "downloading": "กำลังดาวน์โหลด", + "downloading_asset_filename": "กำลังดาวน์โหลด {filename}", + "drop_files_to_upload": "วางไฟล์ในช่องอัปโหลด", + "duplicates": "รายการที่ซ้ำกัน", + "duplicates_description": "แก้ไขแต่ละกลุ่มโดยระบุว่ากลุ่มใดซ้ำกันหากมี", "duration": "ระยะเวลา", + "edit": "แก้ไข", "edit_album": "แก้ไขอัลบั้ม", "edit_avatar": "แก้ไขตัวละคร", "edit_date": "แก้ไขวันที่", @@ -471,42 +579,118 @@ "edit_location": "แก้ไขตำแหน่ง", "edit_name": "แก้ไขชื่อ", "edit_people": "แก้ไขผู้คน", + "edit_tag": "แก้ไขแท็ก", "edit_title": "แก้ไขชื่อ", "edit_user": "แก้ไขผู้ใช้", "edited": "แก้ไขแล้ว", "editor": "ผู้แก้ไข", + "editor_close_without_save_prompt": "การเปลี่ยนแปลงนี้จะไม่ได้รับการบันทึก", + "editor_close_without_save_title": "ปิดโปรแกรมแก้ไข?", + "editor_crop_tool_h2_aspect_ratios": "อัตราส่วนภาพ", + "editor_crop_tool_h2_rotation": "การหมุน", "email": "อีเมล", "empty_trash": "ทิ้งจากถังขยะ", + "empty_trash_confirmation": "คุณแน่ใจหรือไม่ว่าต้องการล้างถังขยะ การดำเนินการนี้จะลบทรัพยากรทั้งหมดในถังขยะออกจาก Immich อย่างถาวร\nคุณไม่สามารถย้อนกลับการดำเนินการนี้ได้!", "enable": "เปิดใช้งาน", "enabled": "เปิดใช้งาน", "end_date": "วันสิ้นสุด", "error": "เกิดข้อผิดพลาด", "error_loading_image": "เกิดข้อผิดพลาดระหว่างโหลดภาพ", + "error_title": "เกิดข้อผิดพลาด", "errors": { + "cannot_navigate_next_asset": "ไม่สามารถเปลี่ยนเส้นทางได้", + "cannot_navigate_previous_asset": "ไม่สามารถเปลี่ยนเส้นทางก่อนหน้าได้", + "cant_apply_changes": "เกิดข้อผิดพลาดในการเปลี่ยนแปลง", + "cant_change_activity": "Can't {enabled, select, true {disable} other {enable}} activity", + "cant_change_asset_favorite": "ไม่สามารถเปลี่ยนสื่อที่ชื่นชอบได้", + "cant_change_metadata_assets_count": "Can't change metadata of {count, plural, one {# asset} other {# assets}}", + "cant_get_faces": "เกิดข้อผิดพลาดในการเรียกดูใบหน้า", + "cant_get_number_of_comments": "ไม่สามารถเรียกดูจำนวนความคิดเห็นได้", + "cant_search_people": "ไม่สามารถค้นหาบุคคลคนได้", + "cant_search_places": "ไม่สามารถค้นหาสถานที่ได้", + "cleared_jobs": "ล้างงาน: {job} สำเร็จ", + "error_adding_assets_to_album": "เกิดข้อผิดพลาดในการเพิ่มสื่อไปยังอัลบั้ม", + "error_adding_users_to_album": "เกิดข้อผิดพลาดในการเพิ่มผู้ใช้ไปยังอัลบั้ม", + "error_deleting_shared_user": "เกิดข้อผิดพลาดในการลบผู้ใช้ที่แชร์", + "error_downloading": "ไม่สามารถดาวน์โหลด {filename} ได้", + "error_hiding_buy_button": "Error hiding buy button", + "error_removing_assets_from_album": "เกิดข้อผิดพลาดในการลบสื่อจากอัลบั้ม", + "error_selecting_all_assets": "เกิดข้อผิดพลาดในการเลือกสื่อทั้งหมด", + "exclusion_pattern_already_exists": "ข้อยกเว้นนี้มีอยู่แล้ว", + "failed_job_command": "คำสั่ง {command} ผิดพลาด สำหรับ: {job}", + "failed_to_create_album": "ไม่สามารถสร้างอัลบั้มได้", + "failed_to_create_shared_link": "ไม่สามารถสร้างลิงก์ที่แชร์ได้", + "failed_to_edit_shared_link": "ไม่สามารถแก้ไขลิงก์ที่แชร์ได้", + "failed_to_get_people": "ไม่สามารถเรียกดูบุคคลได้", + "failed_to_keep_this_delete_others": "ไม่สามารถเก็บหรือลบได้", + "failed_to_load_asset": "ไม่สามารถโหลดสื่อได้", + "failed_to_load_assets": "ไม่สามารถโหลดสื่อได้", + "failed_to_load_people": "ไม่สามารถโหลดบุคคลได้", + "failed_to_remove_product_key": "Failed to remove product key", + "failed_to_stack_assets": "Failed to stack assets", + "failed_to_unstack_assets": "Failed to un-stack assets", "import_path_already_exists": "พาธนำเข้านี้มีอยู่แล้ว", + "incorrect_email_or_password": "อีเมลหรือรหัสผ่านไม่ถูกต้อง", + "paths_validation_failed": "การตรวจสอบ {paths, plural, one {# path} other {# paths}} ล้มเหลว", + "profile_picture_transparent_pixels": "รูปโปรไฟล์ไม่สามารถมีพิกเซลโปร่งใสได้ โปรดซูมเข้าและ/หรือย้ายรูปภาพ", + "quota_higher_than_disk_size": "คุณตั้งโควตาไว้สูงกว่าขนาดดิสก์", + "repair_unable_to_check_items": "ไม่สามารถตรวจสอบ {count, select, one {item} other {items}} ได้", "unable_to_add_album_users": "ไม่สามารถเพิ่มผู้ใช้ไปยังอัลบั้มได้", + "unable_to_add_assets_to_shared_link": "ไม่สามารถเพิ่มลงในลิงก์ที่แชร์ได้", "unable_to_add_comment": "ไม่สามารถเพิ่มความเห็นได้", + "unable_to_add_exclusion_pattern": "ไม่สามารถเพิ่มรูปแบบข้อยกเว้นได้", + "unable_to_add_import_path": "ไม่สามารถเพิ่มเส้นทางนำเข้าได้", "unable_to_add_partners": "ไม่สามารถเพิ่มคู่หูได้", + "unable_to_add_remove_archive": "ไม่สามารถจัดเก็บรายการ {archived, select, true {remove asset from} other {add asset to}} ไปยังการจัดเก็บถาวรได้", + "unable_to_add_remove_favorites": "ไม่สามารถทำรายการ {favorite, select, true {add asset to} other {remove asset from}} เข้ารายการโปรดได้", + "unable_to_archive_unarchive": "ไม่สามารถทำรายการ {archived, select, true {archive} other {unarchive}}", "unable_to_change_album_user_role": "ไม่สามารถเปลี่ยนบทบาทผู้ใช้ในอัลบั้มได้", "unable_to_change_date": "ไม่สามารถเปลี่ยนวันที่ได้", + "unable_to_change_favorite": "ไม่สามารถเปลี่ยนแปลงสื่อรายการโปรดได้", "unable_to_change_location": "ไม่สามารถเปลี่ยนตําแหน่งได้", - "unable_to_create_admin_account": "", + "unable_to_change_password": "ไม่สามารถเปลี่ยนรหัสผ่านได้", + "unable_to_change_visibility": "ไม่สามารถเปลี่ยนการมองเห็นสำหรับ {count, plural, one {# person} other {# people}}", + "unable_to_complete_oauth_login": "ไม่สามารถทำการเข้าสู่ระบบ OAuth ให้เสร็จสมบูรณ์ได้", + "unable_to_connect": "ไม่สามารถเชื่อมต่อได้", + "unable_to_connect_to_server": "ไม่สามารถเชื่อมต่อกับ Server ได้", + "unable_to_copy_to_clipboard": "ไม่สามารถคัดลอกไปยังคลิปบอร์ดได้ ตรวจสอบให้แน่ใจว่าคุณเข้าถึงหน้าผ่านทาง https", + "unable_to_create_admin_account": "ไม่สามารถสร้างบัญชีผู้ดูแลระบบได้", + "unable_to_create_api_key": "ไม่สามารถสร้าง API คีย์ ได้", "unable_to_create_library": "ไม่สามารถสร้างคลังภาพได้", "unable_to_create_user": "ไม่สามารถสร้างผู้ใช้ได้", "unable_to_delete_album": "ไม่สามารถลบอัลบั้มได้", "unable_to_delete_asset": "ไม่สามารถลบสื่อได้", + "unable_to_delete_assets": "เกิดผิดพลาดในการลบ", + "unable_to_delete_exclusion_pattern": "ไม่สามารถลบรูปแบบที่ยกเว้น", + "unable_to_delete_import_path": "ไม่สามารถลบเส้นทางนำเข้าได้", + "unable_to_delete_shared_link": "ไม่สามารถลบลิงก์ที่แชร์ได้", "unable_to_delete_user": "ไม่สามารถลบผู้ใช้ได้", + "unable_to_download_files": "ไม่สามารถดาวน์โหลดไฟล์ได้", + "unable_to_edit_exclusion_pattern": "ไม่สามารถแก้ไขรูปแบบยกเว้นได้", + "unable_to_edit_import_path": "ไม่สามารถแก้ไขเส้นทางนำเข้าได้", "unable_to_empty_trash": "ไม่สามารถลบถังขยะได้", "unable_to_enter_fullscreen": "ไม่สามารถเปิดเต็มจอได้", "unable_to_exit_fullscreen": "ไม่สามารถออกโหมดเต็มจอได้", + "unable_to_get_comments_number": "ไม่สามารถร้องขอจำนวนแสดงความคิดเห็น", + "unable_to_get_shared_link": "การร้องขอลิงก์ที่แชร์ล้มเหลว", "unable_to_hide_person": "ไม่สามารถซ่อนบุคคลได้", + "unable_to_link_motion_video": "ไม่สามารถเชื่อมโยงวิดีโอเคลื่อนไหวได้", + "unable_to_link_oauth_account": "ไม่สามารถเชื่อมโยงบัญชี OAuth ได้", "unable_to_load_album": "ไม่สามารถโหลดอัลบั้มได้", "unable_to_load_asset_activity": "ไม่สามารถโหลดข้อมูลของสื่อได้", "unable_to_load_items": "ไม่สามารถโหลดรายการได้", "unable_to_load_liked_status": "ไม่สามารถโหลดสถานะ like ได้", + "unable_to_log_out_all_devices": "ไม่สามารถออกจากของอุปกรณ์ทั้งหมดได้", + "unable_to_log_out_device": "ไม่สามารถออกจากระบบได้", + "unable_to_login_with_oauth": "ไม่สามารถเข้าสู่ระบบกับ OAuth ได้", "unable_to_play_video": "ไม่สามารถเล่นวิดีโอได้", + "unable_to_reassign_assets_existing_person": "ไม่สามารถกำหนดให้กับ {name, select, null {an existing person} other {{name}}}", + "unable_to_reassign_assets_new_person": "ไม่สามารถมอบหมาย ให้กับบุคคลใหม่ได้", "unable_to_refresh_user": "ไม่สามารถรีเฟรชผู้ใช้ได้", "unable_to_remove_album_users": "ไม่สามารถลบผู้ใช้ออกจากอัลบั้มได้", + "unable_to_remove_api_key": "ไม่สามารถลบ API Key ได้", + "unable_to_remove_assets_from_shared_link": "ไม่สามารถลบออกจากลิงก์ที่แชร์ได้", + "unable_to_remove_deleted_assets": "ไม่สามารถลบไฟล์ออฟไลน์ได้", "unable_to_remove_library": "ไม่สามารถลบคลังภาพได้", "unable_to_remove_partner": "ไม่สามารถลบคู่หูได้", "unable_to_remove_reaction": "ไม่สามารถลบ reaction ได้", @@ -517,53 +701,93 @@ "unable_to_restore_trash": "ไม่สามารถเรียกคืนถังขยะได้", "unable_to_restore_user": "ไม่สามารถเรียกคืนผู้ใช้ได้", "unable_to_save_album": "ไม่สามารถบันทึกอัลบั้มได้", + "unable_to_save_api_key": "ไม่สามารถบันทึก API คีย์ ได้", + "unable_to_save_date_of_birth": "ไม่สามารถตั้งวันเกิดได้", "unable_to_save_name": "ไม่สามารถบันทึกชื่อได้", "unable_to_save_profile": "ไม่สามารถบันทึกโปรไฟล์ได้", "unable_to_save_settings": "ไม่สามารถบันทึกการตั้งค่าได้", "unable_to_scan_libraries": "ไม่สามารถสแกนคลังภาพได้", "unable_to_scan_library": "ไม่สามารถสแกนคลังภาพได้", + "unable_to_set_feature_photo": "ไม่สามารถตั้งรูปภาพเป็น Feature ได้", "unable_to_set_profile_picture": "ไม่สามารถตั้งภาพโปรไฟล์ได้", "unable_to_submit_job": "ไม่สามารถส่งงานได้", "unable_to_trash_asset": "ไม่สามารถทิ้งสื่อได้", "unable_to_unlink_account": "ไม่สามารถยกเลิกการเชื่อมโยงบัญชีผู้ใช้ได้", + "unable_to_unlink_motion_video": "ไม่สามารถแยกการเคลื่อนไหวได้", + "unable_to_update_album_cover": "ไม่สามารถอัปเดตรูปภาพปกอัลบั้มได้", + "unable_to_update_album_info": "ไม่สามารถอัปเดตข้อมูลอัลบั้มได้", "unable_to_update_library": "ไม่สามารถอัพเดทคลังภาพได้", "unable_to_update_location": "ไม่สามารถอัพเดทตําแหน่งได้", "unable_to_update_settings": "ไม่สามารถอัพเดทการตั้งค่าได้", - "unable_to_update_user": "ไม่สามารถอัพเดทผู้ใช้ได้" + "unable_to_update_timeline_display_status": "ไม่สามารถแก้ไขสถานะการแสดงลำดับเวลาได้", + "unable_to_update_user": "ไม่สามารถอัพเดทผู้ใช้ได้", + "unable_to_upload_file": "ไม่สามารถอัปโหลดได้" }, - "exit_slideshow": "", - "expand_all": "", + "exit_slideshow": "ออกจากการนำเสนอ", + "expand_all": "ขยายทั้งหมด", "expire_after": "หมดอายุหลังจาก", "expired": "หมดอายุแล้ว", + "expires_date": "หมดอายุวันที่ {date}", "explore": "สํารวจ", + "explorer": "เครื่องมือสำรวจ", + "export": "ส่งออก", + "export_as_json": "ส่งออกเป็นไฟล์ JSON", "extension": "ส่วนต่อขยาย", + "external": "ภายนอก", "external_libraries": "ภายนอกคลังภาพ", + "face_unassigned": "ไม่กำหนดมอบหมาย", + "failed_to_load_assets": "เกิดข้อผิดพลาดในการโหลดสื่อ", "favorite": "รายการโปรด", "favorite_or_unfavorite_photo": "โปรดหรือไม่โปรดภาพ", "favorites": "รายการโปรด", "feature_photo_updated": "อัพเดทภาพเด่นแล้ว", - "file_name": "", - "file_name_or_extension": "", + "features": "ฟีเจอร์", + "features_setting_description": "จัดการฟีเจอร์แอป", + "file_name": "ชื่อไฟล์", + "file_name_or_extension": "นามสกุลหรือชื่อไฟล์", "filename": "ชื่อไฟล์", "filetype": "ชนิดไฟล์", "filter_people": "กรองผู้คน", - "fix_incorrect_match": "", + "find_them_fast": "ค้นหาโดยชื่ออย่างรวดเร็ว", + "fix_incorrect_match": "แก้ไขการจับคู่ที่ไม่ถูกต้อง", + "folders": "โฟล์เดอร์", + "folders_feature_description": "การเรียกดูมุมมองโฟลเดอร์สำหรับภาพถ่ายและวิดีโอในระบบไฟล์", "forward": "ไปข้างหน้า", "general": "ทั่วไป", - "get_help": "", - "getting_started": "", - "go_back": "", - "go_to_search": "", - "group_albums_by": "", - "has_quota": "", + "get_help": "ขอความช่วยเหลือ", + "getting_started": "เริ่มต้นใช้งาน", + "go_back": "กลับ", + "go_to_folder": "ไปที่โฟล์เดอร์", + "go_to_search": "กลับไปยังการค้นหา", + "group_albums_by": "จัดกลุ่มอัลบั้มตาม", + "group_no": "ไม่จัดกลุ่ม", + "group_owner": "จัดกลุ่มโดยเจ้าของ", + "group_year": "จัดกลุ่มตามปี", + "has_quota": "เหลือพื้นที่", + "hi_user": "สวัสดีคุณ {name} {email}", + "hide_all_people": "ซ่อนบุคคลทั้งหมด", "hide_gallery": "ซ่อนคลังภาพ", - "hide_password": "", + "hide_named_person": "ซ่อน {name}", + "hide_password": "ซ่อนรหัสผ่าน", "hide_person": "ซ่อนบุคคล", + "hide_unnamed_people": "ซ่อนบุคคลที่ไม่ได้ระบุชื่อ", "host": "โฮสต์", "hour": "ชั่วโมง", "image": "รูปภาพ", - "immich_logo": "", - "import_path": "นำเข้าพาธ", + "image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Image}} ถ่ายกับ {person1} วันที่ {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Video} other {Image}} ถ่ายกับ {person1} และ {person2} วันที่ {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Video} other {Image}} ถ่ายกับ {person1}, {person2},และ {person3} วันที่ {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video} other {Image}} ถ่ายกับ {person1}, {person2},และ {additionalCount, number} วันที่ {date}", + "image_alt_text_date_place": "{isVideo, select, true {Video} other {Image}} ถ่ายใน {city}, {country} วันที่ {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Video} other {Image}} ถ่ายใน {city}, {country} กับ {person1} วันที่ {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Video} other {Image}} ถ่ายใน {city}, {country} กับ {person1} และ {person2} วันที่ {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} ถ่ายใน {city}, {country} กับ {person1}, {person2},และ {person3} วันที่ {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} ถ่ายใน {city}, {country} กับ {person1}, {person2}, และ {additionalCount, number} ในวันที่ {date}", + "immich_logo": "โลโก้ Immich", + "immich_web_interface": "หน้าตาเว็บไซต์ Immich", + "import_from_json": "นำเข้าจาก JSON", + "import_path": "นำเข้าเส้นทาง", + "in_albums": "ใน {count, plural, one {# album} other {# albums}}", "in_archive": "ในที่เก็บถาวร", "include_archived": "รวมไฟล์เก็บถาวร", "include_shared_albums": "รวมอัลบั้มที่แชร์กัน", @@ -572,25 +796,33 @@ "info": "ข้อมูล", "interval": { "day_at_onepm": "ทุกวันเวลาบ่ายโมง", - "hours": "", + "hours": "ทุก ๆ {hours, plural, one {hour} other {{hours, number} hours}}", "night_at_midnight": "ทุกเที่ยงคืน", "night_at_twoam": "ทุกวันเวลาตี 2" }, "invite_people": "เชิญผู้คน", "invite_to_album": "เชิญเข้าอัลบั้ม", + "items_count": "{count, plural, one {# รายการ} other {#รายการ}}", "jobs": "งาน", "keep": "เก็บ", + "keep_all": "เก็บทั้งหมด", + "keep_this_delete_others": "เก็บสิ่งนี้ไว้ ลบอันอื่นออก", + "kept_this_deleted_others": "เก็บเนื้อหานี้และลบ {count, plural, one {# Asset} other {# Asset}}", "keyboard_shortcuts": "ปุ่มพิมพ์ลัด", "language": "ภาษา", "language_setting_description": "เลือกภาษาที่ต้องการ", "last_seen": "เห็นล่าสุด", "latest_version": "เวอร์ชันล่าสุด", + "latitude": "ละติจูด", "leave": "ทิ้ง", + "lens_model": "รูปแบบเลนช์", "let_others_respond": "ให้คนอื่นตอบ", "level": "ระดับ", "library": "คลังภาพ", "library_options": "ตัวเลือกคลังภาพ", "light": "สว่าง", + "like_deleted": "ลบที่ถูกใจแล้ว", + "link_motion_video": "ลิงก์วิดีโอเคลื่อนไหว", "link_options": "ตัวเลือกลิงก์", "link_to_oauth": "ลิงก์ไปยัง OAuth", "linked_oauth_account": "ลิงก์บัญชีผู้ใช้ OAuth", @@ -599,10 +831,17 @@ "loading_search_results_failed": "โหลดผลการค้นหาล้มเหลว", "log_out": "ออกจากระบบ", "log_out_all_devices": "ให้ทุกอุปกรณ์ออกจากระบบทั้งหมด", + "logged_out_all_devices": "ออกจากระบบทั้งหมดแล้ว", + "logged_out_device": "ออกจากระบบแล้ว", + "login": "เข้าสู่ระบบ", "login_has_been_disabled": "ปิดการใช้งานการเข้าสู่ระบบแล้ว", + "logout_all_device_confirmation": "คุณต้องการออกจากระบบทุกอุปกรณ์ ใช่หรือไม่ ?", + "logout_this_device_confirmation": "คุณต้องการออกจากระบบใช่หรือไม่ ?", + "longitude": "ลองจิจูด", "look": "ดู", "loop_videos": "วนวิดีโอ", "loop_videos_description": "เปิดเพื่อให้วิดีโอวนลูปในที่ดูรายละเอียด", + "main_branch_warning": "คุณกำลังใช้เวอร์ชันการพัฒนา เราขอแนะนำอย่างยิ่งให้ใช้เวอร์ชันเสถียร !", "make": "สร้าง", "manage_shared_links": "จัดการลิงก์ที่แชร์", "manage_sharing_with_partners": "จัดการการแชร์กับคู่หู", @@ -627,6 +866,7 @@ "merge_people_limit": "คุณรวมใบหน้าได้มากถึง 5 รูปต่อครั้ง", "merge_people_prompt": "คุณต้องการรวมคนพวกนี้หรือไม่ การกระทำนี้ไม่สามารถย้อนกลับได้", "merge_people_successfully": "รวมผู้คนเรียบร้อยแล้ว", + "merged_people_count": "{count, plural, one {# person} other {# people}} ถูกรวมเข้าด้วยกัน", "minimize": "ย่อลง", "minute": "นาที", "missing": "ขาดหาย", @@ -639,7 +879,7 @@ "name_or_nickname": "ชื่อหรือชื่อเล่น", "never": "ไม่เคย", "new_album": "อัลบั้มใหม่", - "new_api_key": "กุญแจ API ใหม่", + "new_api_key": "สร้าง API คีย์ใหม่", "new_password": "รหัสผ่านใหม่", "new_person": "คนใหม่", "new_user_created": "สร้างผู้ใช้ใหม่แล้ว", @@ -650,18 +890,21 @@ "no": "ไม่", "no_albums_message": "สร้างอัลบั้มเพื่อจัดการรูปภาพและวิดีโอของคุณ", "no_albums_with_name_yet": "ดูเหมือนว่าไม่มีอัลบั้มไหนที่ใช้ชื่อนี้", + "no_albums_yet": "ดูเหมือนว่าคุณยังไม่มีอัลบั้มใดๆ", "no_archived_assets_message": "จัดเก็บรูปภาพและวีดิโอถาวรเพื่อซ่อนจากมุมมองคุณ", "no_assets_message": "กดเพื่อใส่ภาพคุณภาพแรก", "no_duplicates_found": "ไม่พบรายการที่ซ้ำกัน", "no_exif_info_available": "ไม่มีข้อมูล exif", - "no_explore_results_message": "", + "no_explore_results_message": "ไม่พบผลลัพธ์ ลองใช้คำค้นหาอื่น ๆ", "no_favorites_message": "เพิ่มรายการโปรดเพื่อค้นหาภาพและวิดีโอที่ดีที่สุดของคุณอย่างรวดเร็ว", "no_libraries_message": "สร้างคลังภาพภายนอกเพื่อดูภาพถ่ายและวิดีโอต่าง ๆ ของคุณ", "no_name": "ไม่มีชื่อ", "no_places": "ไม่มีสถานที่", "no_results": "ไม่มีผลลัพธ์", + "no_results_description": "ลองใช้คำพ้องหรือคำหลักที่กว้างกว่านี้", "no_shared_albums_message": "สร้างอัลบั้มเพื่อแชร์รูปภาพและวิดีโอกับคนในเครือข่ายของคุณ", "not_in_any_album": "ไม่อยู่ในอัลบั้มใด ๆ", + "note_apply_storage_label_to_previously_uploaded assets": "หมายเหตุ: หากต้องการใช้ป้ายกำกับพื้นที่เก็บข้อมูลกับเนื้อหาที่อัปโหลดก่อนหน้านี้ ให้เรียกใช้", "note_unlimited_quota": "หมายเหตุ: กรอก 0 สำหรับโควตาแบบไม่จำกัด", "notes": "หมายเหตุ", "notification_toggle_setting_description": "เปิด/ปิด การแจ้งเตือนอีเมล", @@ -670,11 +913,18 @@ "oauth": "OAuth", "official_immich_resources": "แหล่งข้อมูล Immich อย่างเป็นทางการ", "offline": "ออฟไลน์", - "ok": "โอเค", - "oldest_first": "เก่าสุดก่อน", - "onboarding_welcome_user": "ยินดีต้อนรับ {user}", + "offline_paths": "เส้นทางที่ตั้งออฟไลน์", + "offline_paths_description": "ผลลัพธ์เหล่านี้อาจเกิดจากการลบไฟล์ที่ไม่ได้เป็นส่วนหนึ่งของไลบรารีภายนอกด้วยตนเอง", + "ok": "ตกลง", + "oldest_first": "เรียงเก่าสุดก่อน", + "onboarding": "การเริ่มต้นใช้งาน", + "onboarding_privacy_description": "คุณลักษณะ (ไม่จำเป็น) ต่อไปนี้ต้องอาศัยบริการภายนอก และสามารถปิดใช้งานได้ตลอดเวลาในการตั้งค่าการดูแลระบบ", + "onboarding_theme_description": "เลือกธีมสี คุณสามารถเปลี่ยนแปลงได้ในภายหลังในการตั้งค่าของคุณ", + "onboarding_welcome_description": "มาตั้งค่า Immich ของคุณ ด้วยการตั้งค่าทั่วไปกัน", + "onboarding_welcome_user": "ยินดีต้อนรับคุณ {user}", "online": "ออนไลน์", "only_favorites": "รายการโปรดเท่านั้น", + "open_in_map_view": "เปิดดูในแผนที่", "open_in_openstreetmap": "เปิดใน OpenStreetMap", "open_the_search_filters": "เปิดตัวกรองการค้นหา", "options": "ตัวเลือก", @@ -686,12 +936,12 @@ "other_variables": "ตัวแปรอื่น", "owned": "เป็นเจ้าของ", "owner": "เจ้าของ", - "partner": "คู่หู", + "partner": "พาร์ทเนอร์", "partner_can_access": "{partner} สามารถเข้าถึง", "partner_can_access_assets": "รูปภาพและวิดีโอทั้งหมดยกเว้นที่อยู่ในเก็บถาวรและถูกลบทิ้ง", "partner_can_access_location": "ตำแหน่งที่รูปถูกถ่าย", - "partner_sharing": "การแชร์แบบคู่หู", - "partners": "คู่หู", + "partner_sharing": "แชร์สำหรับพาร์ทเนอร์", + "partners": "พาร์ทเนอร์", "password": "รหัสผ่าน", "password_does_not_match": "รหัสผ่านไม่ตรงกัน", "password_required": "จำเป็นต้องมีรหัสผ่าน", @@ -701,19 +951,28 @@ "hours": "{hours, plural, one {ชั่วโมง} other {# ชั่วโมง}}ที่ผ่านมา", "years": "{years, plural, one {ปี} other {# ปี}}ที่ผ่านมา" }, - "path": "", - "pattern": "", + "path": "เส้นทาง", + "pattern": "รูปแบบ", "pause": "หยุด", - "pause_memories": "", + "pause_memories": "หยุดดูความทรงจํา", "paused": "หยุด", "pending": "กำลังรอ", "people": "ผู้คน", + "people_edits_count": "{count, plural, one {# person} other {# people}} ถูกแก้ไข", + "people_feature_description": "เรียกดูภาพถ่ายและวิดีโอที่จัดกลุ่มตามผู้คน", "people_sidebar_description": "แสดงลิงก์ไปยังผู้คนในแถบด้านข้าง", "permanent_deletion_warning": "แจ้งเตือนการลบถาวร", "permanent_deletion_warning_setting_description": "เตือนเมื่อจะลบสื่อถาวร", "permanently_delete": "ลบถาวร", + "permanently_delete_assets_count": "ลบ {count, plural, one {asset} other {assets}} ทิ้งถาวร", + "permanently_delete_assets_prompt": "คุณแน่ใจหรือไม่ว่าต้องการลบ {count, plural, one {this asset?} other {these # asset?}}อย่างถาวร การดำเนินการนี้จะลบ {count, plural, one {it from its} other {them from their}} อัลบั้มด้วย", "permanently_deleted_asset": "ลบสื่อถาวรแล้ว", + "permanently_deleted_assets_count": "ลบ {count, plural, one {# asset} other {# assets}} เรียบร้อยแล้ว", + "person": "บุคคล", + "photo_shared_all_users": "ดูเหมือนว่าคุณได้แชร์รูปภาพของคุณกับผู้ใช้ทั้งหมด หรือคุณไม่มีผู้ใช้ใดที่จะแชร์ด้วย", "photos": "รูปภาพ", + "photos_and_videos": "รูปภาพ และ วิดีโอ", + "photos_count": "{count, plural, one {{count, number} รูป} other {{count, number} รูป}}", "photos_from_previous_years": "ภาพถ่ายจากปีก่อน", "pick_a_location": "เลือกตําแหน่ง", "place": "สถานที่", @@ -723,87 +982,191 @@ "play_motion_photo": "เล่นภาพวัตถุเคลื่อนไหว", "play_or_pause_video": "เล่นหรือหยุดวิดีโอ", "port": "พอร์ต", - "preset": "", + "preset": "พรีเซ็ต", "preview": "ตัวอย่าง", "previous": "ก่อนหน้า", "previous_memory": "ความทรงจําก่อนหน้า", "previous_or_next_photo": "ภาพก่อนหน้าหรือภาพถัดไป", "primary": "หลัก", + "privacy": "ความเป็นส่วนตัว", + "profile_image_of_user": "รูปภาพโปรไฟล์ของ {user}", "profile_picture_set": "ตั้งภาพโปรไฟล์แล้ว", + "public_album": "อัลบั้มสาธารณะ", "public_share": "แชร์แบบสาธารณะ", + "purchase_account_info": "ผู้สนับสนุน", + "purchase_activated_subtitle": "ขอบคุณสำหรับการสนับสนุน Immich และซอฟต์แวร์เสรี (Open source software)", + "purchase_activated_time": "เปิดใช้งานวันที่ {date, date}", + "purchase_activated_title": "รหัสของคุณถูกเปิดใช้งานเรียบร้อยแล้ว", + "purchase_button_activate": "เปิดใช้งาน", + "purchase_button_buy": "ซื้อ", + "purchase_button_buy_immich": "ซื้อ Immich", + "purchase_button_never_show_again": "ยังไม่ต้องแสดง", + "purchase_button_reminder": "เตือนฉันในอีก 30 วัน", + "purchase_button_remove_key": "ลบรหัส", + "purchase_button_select": "เลือก", + "purchase_failed_activation": "เปิดใช้งานไม่สำเร็จ! โปรดตรวจสอบอีเมลของคุณเพื่อดูรหัสผลิตภัณฑ์ที่ถูกต้อง!", + "purchase_individual_description_1": "สำหรับบุคคลทั่วไป", + "purchase_individual_description_2": "สถานะผู้สนับสนุน", + "purchase_individual_title": "บุคคลทั่วไป", + "purchase_input_suggestion": "มีรหัสผลิตภัณฑ์หรือไม่? ใส่รหัสด้านล่าง", + "purchase_license_subtitle": "ซื้อ Immich เพื่อรองรับการพัฒนาบริการอย่างต่อเนื่อง", + "purchase_lifetime_description": "ซื้อตลอดชีพ", + "purchase_option_title": "ตัวเลือกการซื้อ", + "purchase_panel_info_1": "ทางทีม Immich ต้องใช้เวลาและความพยายามอย่างมากในการพัฒนาระบบนี้ขึ้นมา และเรามีวิศวกรที่ทำงานเต็มเวลาเพื่อพัฒนาให้ดีที่สุดเท่าที่จะทำได้ ภารกิจของเราคือการทำให้ซอฟต์แวร์โอเพ่นซอร์สและแนวทางปฏิบัติทางธุรกิจที่ถูกต้องตามจริยธรรมกลายเป็นแหล่งรายได้ที่ยั่งยืนสำหรับนักพัฒนา และสร้างระบบนิเวศที่เคารพความเป็นส่วนตัวพร้อมทางเลือกอื่นที่เป็นรูปธรรมแทนบริการคลาวด์ที่เอารัดเอาเปรียบ", + "purchase_panel_info_2": "เนื่องจากเราให้คำมั่นว่า จะไม่เพิ่มระบบชำระเงินในระบบของเรา ดังนั้นการซื้อครั้งนี้จะไม่ทำให้คุณได้รับฟีเจอร์เพิ่มเติมใน Immich เป็นพิเศษ เราอาศัยผู้คนแบบท่านในการสนับสนุนการพัฒนาอย่างต่อเนื่องของ Immich", + "purchase_panel_title": "สนับสนุนโครงการนี้", + "purchase_per_server": "ต่อเซิร์ฟเวอร์", + "purchase_per_user": "ต่อผู้ใช้งาน", + "purchase_remove_product_key": "ลบรหัสผลิตภันฑ์", + "purchase_remove_product_key_prompt": "คุณแน่ใจว่าต้องการลบรหัสผลิตภัณฑ์หรือไม่?", + "purchase_remove_server_product_key": "ลบรหัสผลิตภัณฑ์เซิร์ฟเวอร์", + "purchase_remove_server_product_key_prompt": "คุณแน่ใจหรือไม่ว่าต้องการลบรหัสผลิตภัณฑ์เซิร์ฟเวอร์?", + "purchase_server_description_1": "สำหรับทั้งเซิฟเวอร์", + "purchase_server_description_2": "สถานะผู้สนับสนุน", + "purchase_server_title": "เซิฟเวอร์", + "purchase_settings_server_activated": "รหัสผลิตภัณฑ์เซิร์ฟเวอร์ได้รับการจัดการโดยผู้ดูแลระบบ", + "rating": "การให้คะแนน", + "rating_clear": "ล้างคะแนน", + "rating_count": "{count, plural, one {# ดาว} other {# ดาว}}", + "rating_description": "แสดงคะแนน EXIF ใน Info panel", "reaction_options": "ตัวเลือก reaction", "read_changelog": "อ่านบันทึกการเปลี่ยนแปลง", + "reassign": "มอบหมายใหม่", + "reassigned_assets_to_existing_person": "มอบหมาย {count, plural, one {# สื่อ} other {# สื่อ}} ให้กับ {name, select, null {an existing person} other {{name}}}", + "reassigned_assets_to_new_person": "มอบหมาย {count, plural, one {# สื่อ} other {# สื่อ}} ให้กับบุคคลใหม่", + "reassing_hint": "มอบหมายสื่อที่เลือกให้กับบุคคลที่มีอยู่แล้ว", "recent": "ล่าสุด", + "recent-albums": "อัลบั้มล่าสุด", "recent_searches": "การค้นหาล่าสุด", "refresh": "รีเฟรช", + "refresh_encoded_videos": "โหลดการ encoded วิดีโอใหม่", + "refresh_faces": "รีเฟรชใบหน้า", + "refresh_metadata": "รีเฟรชข้อมูลเมตาดาต้า", + "refresh_thumbnails": "รีโหลดรูป thumbnails", "refreshed": "รีเฟรช", "refreshes_every_file": "รีเฟรชทุกไฟล์", + "refreshing_encoded_video": "กำลังรีเฟรชการเข้ารหัสวิดีโอ", + "refreshing_faces": "กำลังรีเฟรชใบหน้า", + "refreshing_metadata": "กำลังรีเฟรชข้อมูลเมตาดาต้า", + "regenerating_thumbnails": "กำลังสร้างรูป thumbnails ใหม่", "remove": "ลบ", - "remove_deleted_assets": "", + "remove_assets_album_confirmation": "คุณแน่ใจที่จะลบ {count, plural, one {# สื่อ} other {# สื่อ}} ออกจากอัลบั้ม ?", + "remove_assets_shared_link_confirmation": "คุณแน่ใจที่จะลบ {count, plural, one {# สื่อ} other {# สื่อ}} ออกจากลิงก์ที่แชร์นี้ ?", + "remove_assets_title": "ลบสื่อใช่ไหม ?", + "remove_custom_date_range": "ลบการปรับช่วงเวลา", + "remove_deleted_assets": "ลบสื่อที่ถูกลบ", "remove_from_album": "ลบออกจากอัลบั้ม", "remove_from_favorites": "เอาออกจากรายการโปรด", "remove_from_shared_link": "ลบออกจากลิงก์ที่แชร์", - "repair": "ซ่อม", - "repair_no_results_message": "", - "replace_with_upload": "", + "remove_url": "ลบ URL", + "remove_user": "ลบผู้ใช้", + "removed_api_key": "API คีย์ของ: {name} ถูกลบแล้ว", + "removed_from_archive": "ลบจากเก็บถาวรแล้ว", + "removed_from_favorites": "ลบจากรายการโปรดแล้ว", + "removed_from_favorites_count": "{count, plural, other {ถูกลบ#}} จากรายการโปรดแล้ว", + "removed_tagged_assets": "ลบแท็กจาก {count, plural, one {# สื่อ} other {# สื่อ}}", + "rename": "เปลี่ยนชื่อ", + "repair": "ซ่อมแซม", + "repair_no_results_message": "ไม่สามารถซ่อมแซมได้", + "replace_with_upload": "อัปโหลดทับรูปหรือวิดีโอนี้", "require_password": "ต้องการรหัสผ่าน", + "require_user_to_change_password_on_first_login": "จำเป็นต้องเปลี่ยนรหัสผ่าน ในการเข้าสู่ระบบครั้งแรก", "reset": "รีเซ็ต", "reset_password": "ตั้งค่ารหัสผ่านใหม่", "reset_people_visibility": "ปรับการมองเห็นใหม่", + "reset_to_default": "กลับไปค่าเริ่มต้น", + "resolve_duplicates": "แก้ไขข้อมูลซ้ำซ้อน", + "resolved_all_duplicates": "แก้ไขข้อมูลซ้ำซ้อนทั้งหมด", "restore": "เรียกคืน", + "restore_all": "เรียกคืนทั้งหมด", "restore_user": "เรียกคืนผู้ใช้", - "retry_upload": "ลองอัพโหลดใหม่", - "review_duplicates": "", + "restored_asset": "asset ถูกคืนค่า", + "resume": "กลับคืน", + "retry_upload": "ลองอัปโหลดใหม่", + "review_duplicates": "ตรวจสอบรายการที่ซ้ำกัน", "role": "บทบาท", + "role_editor": "เครื่องมือแก้ไข", + "role_viewer": "ดู", "save": "บันทึก", - "saved_profile": "โพรไฟล์ที่บันทึกไว้", - "saved_settings": "การตั้งค่าที่บันทึกไว้", + "saved_api_key": "บันทึก API คีย์ แล้ว", + "saved_profile": "แก้ไขโปรไฟล์สำเร็จ", + "saved_settings": "บันทึกการตั้งค่าสำเร็จ", "say_something": "พูดอะไรสักอย่าง", "scan_all_libraries": "สแกนคลังภาพทั้งหมด", + "scan_library": "สแกน", "scan_settings": "ตั้งค่าการสแกน", + "scanning_for_album": "กำลังสแกนอัลบั้ม...", "search": "ค้นหา", - "search_albums": "", - "search_by_context": "", - "search_camera_make": "", - "search_camera_model": "", - "search_city": "", - "search_country": "", - "search_for_existing_person": "", + "search_albums": "ค้นหาอัลบั้ม", + "search_by_context": "ค้นหาตามบริบท", + "search_by_filename": "ค้นหาชื่อไฟล์ชื่อ หรือ ชนิดไฟล์", + "search_by_filename_example": "ตัวอย่าง. IMG_1234.JPG หรือ PNG", + "search_camera_make": "ค้นหายี่ห้อกล้อง", + "search_camera_model": "ค้นหารุ่นกล้อง", + "search_city": "ค้นหาตามเมือง", + "search_country": "ค้นหาตามประเทศ", + "search_for": "การค้นหาสำหรับ", + "search_for_existing_person": "ค้นหาบุคคลที่มีอยู่", + "search_no_people": "ไม่พบบุคคลคน", + "search_no_people_named": "ไม่พบ \"{name}\"", + "search_options": "ตัวเลือกการค้นหา", "search_people": "ค้นหาผู้คน", - "search_places": "", - "search_state": "", - "search_timezone": "", - "search_type": "", + "search_places": "ค้นหาสถานที่", + "search_settings": "ตั้งค่าการค้นหา", + "search_state": "ค้นหาตามรัฐ", + "search_tags": "ค้นหาแท็ก", + "search_timezone": "ค้นหาตามวันที่และเวลา", + "search_type": "ค้นหาตามประเภท", "search_your_photos": "ค้นหารูปภาพของคุณ", - "searching_locales": "", + "searching_locales": "ค้นหาตามภูมิภาค", "second": "วินาที", - "select_album_cover": "", - "select_all": "", - "select_avatar_color": "", - "select_face": "", + "see_all_people": "ดูบุคคลทั้งหมด", + "select_album_cover": "เลือกภาพปกอัลบั้ม", + "select_all": "เลือกทั้งหมด", + "select_all_duplicates": "เลือกรายการที่ซ้ำทั้งหมด", + "select_avatar_color": "เลือกสีพื้นหลังของรูปโปรไฟล์", + "select_face": "เลือกใบหน้า", "select_featured_photo": "เลือกภาพเด่น", + "select_from_computer": "เลือกจากคอมพิวเตอร์", + "select_keep_all": "เลือกเก็บทั้งหมด", "select_library_owner": "เลือกเจ้าของคลังภาพ", - "select_new_face": "", + "select_new_face": "เลือกใบหน้าใหม่", "select_photos": "เลือกรูปภาพ", + "select_trash_all": "เลือกในถังขยะทั้งหมด", "selected": "เลือก", - "send_message": "", - "server_stats": "", - "set": "", - "set_as_album_cover": "", - "set_as_profile_picture": "", - "set_date_of_birth": "", - "set_profile_picture": "", - "set_slideshow_to_fullscreen": "", + "selected_count": "{count, plural, other {# เลือกแล้ว}}", + "send_message": "ส่งข้อความ", + "send_welcome_email": "ส่งอีเมลต้อนรับ", + "server_offline": "Server ออฟไลน์", + "server_online": "Server ออนไลน์", + "server_stats": "สถิติเซิร์ฟเวอร์", + "server_version": "เวอร์ชันของ Server", + "set": "ตั้ง", + "set_as_album_cover": "ตั้งเป็นภาพปกอัลบั้ม", + "set_as_featured_photo": "ตั้งเป็นรูปสำคัญ", + "set_as_profile_picture": "ตั้งเป็นรูปโปรไฟล์", + "set_date_of_birth": "ตั้งวันเกิด", + "set_profile_picture": "ตั้งรูปโปรไฟล์", + "set_slideshow_to_fullscreen": "ตั้งค่าการนำเสนอเต็มจอ", "settings": "ตั้งค่า", "settings_saved": "บันทึกการตั้งค่าแล้ว", "share": "แชร์", "shared": "แชร์", "shared_by": "แชร์โดย", + "shared_by_user": "แชร์โดย {user}", "shared_by_you": "แชร์โดยคุณ", + "shared_from_partner": "รูปจาก {partner}", + "shared_link_options": "ตั้งค่าลิงก์ที่แชร์", "shared_links": "ลิงก์ที่แชร์", + "shared_with_partner": "แชร์กับ {partner}", "sharing": "การแชร์", - "sharing_sidebar_description": "", + "sharing_enter_password": "โปรดป้อนรหัสผ่าน สำหรับเปิดดูหน้านี้", + "sharing_sidebar_description": "แสดงลิงก์ที่แชร์ในแถบด้านข้าง", + "shift_to_permanent_delete": "กด ⇧ to สำหรับลบสื่อถาวร", "show_album_options": "แสดงตัวเลือกอัลบั้ม", + "show_albums": "แสดงอัลบั้ม", + "show_all_people": "แสดงบุคคลทั้งหมด", + "show_and_hide_people": "แสดง & ซ่อนบุคคล", "show_file_location": "แสดงตําแหน่งของไฟล์", "show_gallery": "แสดงคลังภาพ", "show_hidden_people": "แสดงคนที่ซ่อนไว้", @@ -816,13 +1179,29 @@ "show_person_options": "แสดงตัวเลือกของตัวบุคคล", "show_progress_bar": "แสดงความคืบหน้า แถบ", "show_search_options": "แสดงตัวเลือกการค้นหา", + "show_slideshow_transition": "แสดงสไลค์โชว์", + "show_supporter_badge": "เครื่องหมายผู้สนับสนุน", + "show_supporter_badge_description": "แสดงเครื่องหมายผู้สนับสนุน", "shuffle": "สับเปลี่ยน", + "sidebar": "แถบด้านข้าง", + "sidebar_display_description": "เปิดหรือปิดแถบด้านข้าง", + "sign_out": "ออกจากระบบ", "sign_up": "ลงทะเบียน", "size": "ขนาด", "skip_to_content": "ข้ามไปยังเนื้อหา", + "skip_to_folders": "ข้ามโฟล์เดอร์", + "skip_to_tags": "ข้ามแท็ก", "slideshow": "สไลด์", "slideshow_settings": "ตั้งค่าสไลด์", - "sort_albums_by": "เรียงอัลบั้มโดย...", + "sort_albums_by": "จัดเรียงอัลบั้มโดย...", + "sort_created": "จัดเรียงตามวันที่สร้าง", + "sort_items": "จัดเรียงรายการ", + "sort_modified": "จัดเรียงตามวันที่แก้ไข", + "sort_oldest": "จัดเรียงตามเก่าสุด", + "sort_people_by_similarity": "จุดเรียงบุคคลตามความคล้ายคลึง", + "sort_recent": "จัดเรียงใหม่ล่าสุด", + "sort_title": "ไตเติ้ล", + "source": "แหล่ง", "stack": "ซ้อน", "stack_selected_photos": "", "stacktrace": "", @@ -831,67 +1210,105 @@ "status": "สถานะ", "stop_motion_photo": "ภาพวัตถุเคลื่อนไหว", "stop_photo_sharing": "หยุดแชร์รูปภาพ?", - "storage": "ที่จัดเก็บ", - "storage_label": "ฉลากจัดเก็บ", + "stop_sharing_photos_with_user": "หยุดการแชร์รูปภาพของคุณกับผู้ใช้นี้", + "storage": "พื้นที่จัดเก็บ", + "storage_label": "เนื้อที่จัดเก็บ", + "storage_usage": "ใช้ไป {used} จาก {available} ", "submit": "ส่ง", "suggestions": "ข้อเสนอแนะ", "sunrise_on_the_beach": "พระอาทิตย์ขึ้นบนชายหาด", + "support": "สนับสนุน", + "support_and_feedback": "สนับสนุน & ข้อเสนอแนะ", + "support_third_party_description": "การติดตั้ง Immich ของคุณถูกจัดทำแพ็กเกจโดยบุคคลที่สาม ปัญหาที่คุณพบอาจเกิดจากแพ็กเกจดังกล่าว ดังนั้นโปรดแจ้งปัญหาที่เกิดขึ้นกับบุคคลที่สามก่อนโดยใช้ลิงก์ด้านล่าง", "swap_merge_direction": "สลับด้านรวม", "sync": "ซิงค์", - "template": "แม่แบบ", + "tag": "แท็ก", + "tag_created": "สร้างแท็ก: {tag}", + "template": "เท็มเพลต", "theme": "ธีม", "theme_selection": "การเลือกธีม", "theme_selection_description": "ตั้งค่าธีมให้สว่างหรือมืดโดยอัตโนมัติ อิงจากค่าของเบราว์เซอร์ของคุณ", + "third_party_resources": "ทรัพยากรบุคคลที่สาม", "time_based_memories": "ความทรงจําตามเวลา", + "timeline": "Timeline", "timezone": "เขตเวลา", + "to_archive": "จัดเก็บถาวร", + "to_change_password": "Change password", + "to_favorite": "รายการโปรด", + "to_login": "เข้าสู่ระบบ", + "to_parent": "ไปยังบนสุด", + "to_trash": "ถังขยะ", "toggle_settings": "สลับการตั้งค่า", "toggle_theme": "สลับธีม", "total_usage": "การใช้งานรวม", - "trash": "ขยะ", + "trash": "ถังขยะ", "trash_all": "ทิ้งทั้งหมด", - "trash_no_results_message": "รูปและวีดีโอที่ถูกทิ้งจะมาโผล่ที่นี่", + "trash_count": "{count, number} ในถังขยะ", + "trash_no_results_message": "รูปภาพหรือวิดีโอที่ถูกลบจะอยู่ที่นี่", + "trashed_items_will_be_permanently_deleted_after": "รายการที่ถูกลบจะถูกลบทิ้งภายใน {days, plural, one {# วัน} other {# วัน}}.", "type": "ประเภท", "unarchive": "นำออกจากที่เก็บถาวร", "unfavorite": "นำออกจากรายการโปรด", "unhide_person": "ยกเลิกซ่อนบุคคล", "unknown": "ไม่ทราบ", "unknown_year": "ไม่ทราบปี", - "unlink_oauth": "", - "unlinked_oauth_account": "", + "unlimited": "ไม่จำกัด", + "unlink_oauth": "ยกเลิกเชื่อมต่อ OAuth", + "unlinked_oauth_account": "ยกเลิกการเชื่อมต่อ OAuth", + "unnamed_album": "อัลบั้มไม่มีชื่อ", + "unnamed_album_delete_confirmation": "คุณต้องการจะลบอัลบั้มนี้ ใช่หรือไม่ ?", + "unnamed_share": "แชร์แบบไม่ระบุชื่อ", "unselect_all": "ยกเลิกการเลือกทั้งหมด", "unstack": "หยุดซ้อน", "up_next": "ต่อไป", "updated_password": "รหัสผ่านเปลี่ยนแล้ว", - "upload": "อัพโหลด", - "upload_concurrency": "อัพโหลดพร้อมกัน", + "upload": "อัปโหลด", + "upload_concurrency": "อัปโหลดพร้อมกัน", + "upload_status_duplicates": "รวมเข้าด้วยกัน", + "upload_status_errors": "ข้อผิดพลาด", + "upload_status_uploaded": "อัปโหลดแล้ว", + "upload_success": "อัปโหลดสำเร็จ, รีเฟรชหน้านี้ใหม่คุณจะเห็นสื่อที่เพิ่มล่าสุด", "url": "URL", "usage": "การใช้งาน", + "use_custom_date_range": "ใช้การปรับแต่งช่วงเวลา", "user": "ผู้ใช้", "user_id": "ไอดีผู้ใช้", + "user_purchase_settings": "ซื้อ", + "user_purchase_settings_description": "จัดการการซื้อ", + "user_role_set": "ตั้ง {role} ให้กับ {user}", "user_usage_detail": "รายละเอียดการใช้งานของผู้ใช้", "user_usage_stats": "สถิติการใช้งานบัญชี", "user_usage_stats_description": "ดูสถิติการใช้งานบัญชี", "username": "ชื่อผู้ใช้", "users": "ผู้ใช้", - "utilities": "", + "utilities": "เครื่องมือ", "validate": "ตรวจสอบ", "variables": "ตัวแปร", "version": "รุ่น", + "version_announcement_message": "สวัสดี! Immich เวอร์ชันใหม่พร้อมให้ใช้งานแล้ว โปรดใช้เวลาสักครู่เพื่ออ่าน หมายเหตุการเผยแพร่ เพื่อให้แน่ใจว่าการตั้งค่าของคุณได้รับการอัปเดตแล้ว เพื่อป้องกันการกำหนดค่าผิดพลาด โดยเฉพาะอย่างยิ่งหากคุณใช้ WatchTower หรือกลไกอื่นๆ ที่จัดการการอัปเดตอินสแตนซ์ Immich ของคุณโดยอัตโนมัติ", + "version_history": "การเปลี่ยนแปลง", + "version_history_item": "ติดตั้ง {version} วันที่ {date}", "video": "วิดีโอ", - "video_hover_setting": "เล่นวิดีโอตัวอย่างเมื่อจ่อ", + "video_hover_setting": "เล่นวิดีโอแบบย่อเมื่อเลื่อนเมาส์อยู่บน", "video_hover_setting_description": "เล่นวิดีโอตัวอย่างเมื่อเมาส์จ่อข้างบน เมื่อปิดใช้งาน วิดีโอตัวอย่างยังสามารถเล่นได้โดยกดปุ่มเล่น", "videos": "วิดีโอ", + "view": "ดู", + "view_album": "ดูอัลบั้ม", "view_all": "ดูทั้งหมด", "view_all_users": "ดูผู้ใช้ทั้งหมด", + "view_in_timeline": "ดูไทม์ไลน์", "view_links": "ดูลิงก์", "view_next_asset": "ดูสื่อถัดไป", "view_previous_asset": "ดูสื่อก่อนหน้า", + "visibility_changed": "เปลี่ยนแปลงการมองเห็นสำหรับ {count, plural, one {# บุคคล} other {# บุคคล}}", "waiting": "กำลังรอ", + "warning": "คำเตือน", "week": "สัปดาห์", "welcome": "ยินดีต้อนรับ", "welcome_to_immich": "ยินดีต้อนรับสู่ immich", "year": "ปี", + "years_ago": "{years, plural, one {# ปี} other {# ปี}} ที่แล้ว", "yes": "ใช่", "you_dont_have_any_shared_links": "คุณไม่ได้มีลิงก์ที่แชร์", "zoom_image": "ซูมรูปภาพ" -} +} \ No newline at end of file diff --git a/i18n/tr.json b/i18n/tr.json index 10bc00cbf4..4727be7fde 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -20,7 +20,7 @@ "add_partner": "Partner ekle", "add_path": "Yol ekle", "add_photos": "Fotoğraf ekle", - "add_to": "Şuraya ekle...", + "add_to": "Şuraya ekle…", "add_to_album": "Albüme ekle", "add_to_shared_album": "Paylaşılan albüme ekle", "add_url": "URL ekle", @@ -41,6 +41,7 @@ "backup_settings": "Yedekleme Ayarları", "backup_settings_description": "Veritabanı Yedekleme Ayarlarını Yönet", "check_all": "Hepsini Kontrol Et", + "cleanup": "Temizle", "cleared_jobs": "{job} için işler temizlendi", "config_set_by_file": "Ayarlar şuanda config dosyası tarafından ayarlanmıştır", "confirm_delete_library": "{library} kütüphanesini silmek istediğinize emin misiniz?", @@ -96,7 +97,7 @@ "library_scanning_enable_description": "Periyodik kütüphane taramasını etkinleştir", "library_settings": "Harici kütüphane", "library_settings_description": "Harici kütüphane ayarlarını yönet", - "library_tasks_description": "Kütüphane görevleri gerçekleştir", + "library_tasks_description": "Yeni yada değiştirilmiş varlıklar için dış kütüphaneleri tara", "library_watching_enable_description": "Harici kütüphanelerdeki dosya değişikliklerini izle", "library_watching_settings": "Kütüphane izleme (DENEYSEL)", "library_watching_settings_description": "Değişen dosyalar için otomatik olarak izle", @@ -114,7 +115,7 @@ "machine_learning_facial_recognition": "Yüz Tanıma", "machine_learning_facial_recognition_description": "Fotoğraflardaki yüzleri tara, tanı ve gruplandır", "machine_learning_facial_recognition_model": "Yüz Tanıma Modeli", - "machine_learning_facial_recognition_model_description": "Modeller boyutlarına göre sıralanmıştır. Büyük modeller daha yavaş çalışır ve boyutu daha yüksektir fakat daha iyi sonuçlar üretir. Modeli değiştirdikten sonra Yüz Tarama işini tüm fotoğraflar için tekrar çalıştırmalısınız.", + "machine_learning_facial_recognition_model_description": "Modeller, azalan boyut sırasına göre listelenmiştir. Daha büyük modeller daha yavaştır ve daha fazla bellek kullanır, ancak daha iyi sonuçlar üretir. Bir modeli değiştirdikten sonra tüm görüntüler için yüz algılama işini yeniden çalıştırmanız gerektiğini unutmayın.", "machine_learning_facial_recognition_setting": "Yüz Tanımayı etkinleştir", "machine_learning_facial_recognition_setting_description": "Devre dışı bırakıldığında fotoğraflar yüz tanıma için işlenmeyecek ve Keşfet sayfasındaki Kişiler sekmesini doldurmayacak.", "machine_learning_max_detection_distance": "Maksimum tespit uzaklığı", @@ -147,6 +148,8 @@ "map_settings": "Harita", "map_settings_description": "Harita ayarlarını yönet", "map_style_description": "style.json Harita ayarlarının URL'si", + "memory_cleanup_job": "Anı temizliği", + "memory_generate_job": "Anı oluşturma", "metadata_extraction_job": "Meta verilerinden Ayıkla", "metadata_extraction_job_description": "GPS ve çözünürlük gibi ger bir varlığın meta veri bilgilerini ayıklayın", "metadata_faces_import_setting": "Yüz içe aktarmayı etkinleştir", @@ -219,7 +222,7 @@ "reset_settings_to_default": "Ayarları varsayılana sıfırla", "reset_settings_to_recent_saved": "Ayarları kaydedilmiş önceki ayarlara döndür", "scanning_library": "Kütüphaneyi tarama", - "search_jobs": "Görevleri Ara...", + "search_jobs": "Görevler ara…", "send_welcome_email": "Hoş geldin e-postası gönder", "server_external_domain_settings": "Dış domain", "server_external_domain_settings_description": "Paylaşılan fotoğraflar için domain, http(s):// dahil", @@ -326,7 +329,7 @@ "transcoding_transcode_policy_description": "Bir videonun ne zaman kod dönüştürülmesi gerektiğine ilişkin ilke. Dönüştürme devre dışı bırakılmadığı sürece HDR videolar her zaman dönüştürülür.", "transcoding_two_pass_encoding": "İki geçişli kodlama", "transcoding_two_pass_encoding_setting_description": "Daha iyi kodlanmış videolar üretmek için iki geçişte kod dönüştürün. Maksimum bit hızı etkinleştirildiğinde (H.264 ve HEVC ile çalışması için gereklidir), bu mod maksimum bit hızına dayalı bir bit hızı aralığı kullanır ve CRF'yi yok sayar. VP9 için, maksimum bit hızı devre dışı bırakılırsa CRF kullanılabilir.", - "transcoding_video_codec": "Video kodek", + "transcoding_video_codec": "Video kodlayıcı", "transcoding_video_codec_description": "VP9 yüksek verimliliğe ve web uyumluluğuna sahiptir, ancak kod dönüştürme işlemi daha uzun sürer. HEVC benzer performans gösterir ancak web uyumluluğu daha düşüktür. H.264 geniş çapta uyumludur ve kod dönüştürmesi hızlıdır, ancak çok daha büyük dosyalar üretir. AV1 en verimli codec'tir ancak eski cihazlarda desteği yoktur.", "trash_enabled_description": "Çöp özelliklerini etkinleştir", "trash_number_of_days": "Gün sayısı", @@ -391,6 +394,7 @@ "allow_edits": "Düzenlemeye izin ver", "allow_public_user_to_download": "Genel kullanıcının indirmesine aç", "allow_public_user_to_upload": "Genel kullanıcının yüklemesine aç", + "alt_text_qr_code": "QR kodu görseli", "anti_clockwise": "Saat yönünün tersine", "api_key": "API Anahtarı", "api_key_description": "Bu değer sadece bir kere gösterilecek. Lütfen bu pencereyi kapatmadan önce kopyaladığınıza emin olun.", @@ -406,17 +410,17 @@ "are_these_the_same_person": "Bunlar aynı kişi mi?", "are_you_sure_to_do_this": "Bunu yapmak istediğinize emin misiniz?", "asset_added_to_album": "Albüme eklendi", - "asset_adding_to_album": "Şu albüme ekleniyor...", + "asset_adding_to_album": "Albüme ekleniyor…", "asset_description_updated": "Varlık açıklaması güncellendi", "asset_filename_is_offline": "Varlık {filename} çevrimdışı", "asset_has_unassigned_faces": "Varlık, atanmamış yüzler içeriyor", - "asset_hashing": "Karma (hashleme) oluşturuluyor...", + "asset_hashing": "Karma (hashleme) oluşturuluyor…", "asset_offline": "Varlık Çevrim Dışı", "asset_offline_description": "Bu harici varlık artık diskte bulunmuyor. Yardım için lütfen Immich yöneticinizle iletişime geçin.", "asset_skipped": "Atlandı", "asset_skipped_in_trash": "Çöpte", "asset_uploaded": "Yüklendi", - "asset_uploading": "Yükleniyor...", + "asset_uploading": "Yükleniyor…", "assets": "Varlıklar", "assets_added_count": "{count, plural, one {# varlık eklendi} other {# varlık eklendi}}", "assets_added_to_album_count": "{count, plural, one {# varlık} other {# varlık}} albüme eklendi", @@ -481,6 +485,7 @@ "comments_are_disabled": "Yorumlar devre dışı", "confirm": "Onayla", "confirm_admin_password": "Yönetici Şifresini Onayla", + "confirm_delete_face": "Varlıktan {name} yüzünü silmek istediğinizden emin misiniz?", "confirm_delete_shared_link": "Bu paylaşılan bağlantıyı silmek istediğinizden emin misiniz?", "confirm_keep_this_delete_others": "Yığındaki diğer tüm öğeler bu varlık haricinde silinecektir. Devam etmek istediğinizden emin misiniz?", "confirm_password": "Şifreyi onayla", @@ -533,6 +538,7 @@ "delete_album": "Albümü sil", "delete_api_key_prompt": "Bu API anahtarını silmek istediğinizden emin misiniz?", "delete_duplicates_confirmation": "Bu kopyaları kalıcı olarak silmek istediğinizden emin misiniz?", + "delete_face": "Yüzü sil", "delete_key": "Anahtarı sil", "delete_library": "Kütüphaneyi sil", "delete_link": "Bağlantıyı sil", @@ -600,6 +606,7 @@ "enabled": "Etkinleştirildi", "end_date": "Bitiş tarihi", "error": "Hata", + "error_delete_face": "Yüzü varlıktan silme hatası", "error_loading_image": "Resim yüklenirken hata oluştu", "error_title": "Bir Hata Oluştu - Bir şeyler ters gitti", "errors": { @@ -766,8 +773,10 @@ "go_to_folder": "Klasöre git", "go_to_search": "Aramaya git", "group_albums_by": "Albümleri gruplandır...", + "group_country": "Ülkeye göre grupla", "group_no": "Gruplama yok", "group_owner": "Sahibe göre gruplandır", + "group_places_by": "Yerleri gruplandır...", "group_year": "Yıla göre grupla", "has_quota": "Kota var", "hi_user": "Merhaba {name} {email}", @@ -800,6 +809,7 @@ "include_shared_albums": "Paylaşılmış albümleri dahil et", "include_shared_partner_assets": "Paylaşılan ortak varlıkları dahil et", "individual_share": "Bireysel paylaşım", + "individual_shares": "Kişisel paylaşımlar", "info": "Bilgi", "interval": { "day_at_onepm": "Her gün saat 13:00'te", @@ -822,6 +832,7 @@ "latest_version": "En son versiyon", "latitude": "Enlem", "leave": "Ayrıl", + "lens_model": "Mercek modeli", "let_others_respond": "Diğerlerinin yanıt vermesine izin ver", "level": "Seviye", "library": "Kütüphane", @@ -880,6 +891,7 @@ "month": "Ay", "more": "Daha fazla", "moved_to_trash": "Çöp kutusuna taşındı", + "mute_memories": "Anıları sessize al", "my_albums": "Albümlerim", "name": "İsim", "name_or_nickname": "İsim veya takma isim", @@ -984,6 +996,7 @@ "pick_a_location": "Bir konum seçin", "place": "Konum", "places": "Konumlar", + "places_count": "{count, plural, one {{count, number} yer} other {{count, number} yer}}", "play": "Oynat", "play_memories": "Anıları oynat", "play_motion_photo": "Hareketli fotoğrafı oynat", @@ -1071,6 +1084,8 @@ "removed_from_archive": "Arşivden çıkarıldı", "removed_from_favorites": "Favorilerden kaldırıldı", "removed_from_favorites_count": "{count, plural, other {#}} favorilerden çıkarıldı", + "removed_memory": "Anı kaldırıldı", + "removed_photo_from_memory": "Fotoğraf anıdan kaldırıldı", "removed_tagged_assets": "{count, plural, one {# dosya} other {# dosya}} etiketleri kaldırıldı", "rename": "Yeniden adlandır", "repair": "Onar", @@ -1079,6 +1094,7 @@ "repository": "Depo", "require_password": "Şifre gerekli", "require_user_to_change_password_on_first_login": "Kullanıcı ilk girişte şifreyi değiştirmeli", + "rescan": "Yeniden tara", "reset": "Sıfırla", "reset_password": "Şifreyi sıfırla", "reset_people_visibility": "Kişilerin görünürlüğünü sıfırla", @@ -1107,18 +1123,22 @@ "search": "Ara", "search_albums": "Albüm ara", "search_by_context": "Bağlama göre ara", + "search_by_description": "Açıklamaya göre ara", + "search_by_description_example": "Sapa'da yürüyüş günü", "search_by_filename": "Dosya adına göre ara", "search_by_filename_example": "Örn. IMG_1234.JPG veya PNG", "search_camera_make": "Kamera markasına göre ara...", "search_camera_model": "Kamera modeline göre ara...", "search_city": "Şehre göre ara...", "search_country": "Ülkeye göre ara...", + "search_for": "Araştır", "search_for_existing_person": "Mevcut bir kişiyi ara", "search_no_people": "Kişi yok", "search_no_people_named": "\"{name}\" isimli bir kişi yok", "search_options": "Arama seçenekleri", "search_people": "Kişilere göre ara", "search_places": "Yerleri ara", + "search_rating": "Derecelendirerek arayın...", "search_settings": "Ayarları ara", "search_state": "Eyalet/İl ara...", "search_tags": "Etiketleri ara...", @@ -1165,6 +1185,7 @@ "shared_from_partner": "{partner} tarafından paylaşılan fotoğraflar", "shared_link_options": "Paylaşılan bağlantı seçenekleri", "shared_links": "Paylaşılan bağlantılar", + "shared_links_description": "Fotoğraf ve videoları bir bağlantı ile paylaş", "shared_photos_and_videos_count": "{assetCount, plural, one {# paylaşılan fotoğraf veya video.} other {# paylaşılan fotoğraf & video.}}", "shared_with_partner": "{partner} ile paylaşıldı", "sharing": "Paylaşılıyor", @@ -1187,6 +1208,7 @@ "show_person_options": "Kişi ayarlarını göster", "show_progress_bar": "İlerleme çubuğunu göster", "show_search_options": "Arama ayarlarını göster", + "show_shared_links": "Paylaşılan bağlantıları göster", "show_slideshow_transition": "Slayt geçişini göster", "show_supporter_badge": "Destekçi rozeti", "show_supporter_badge_description": "Destekçi rozetini göster", @@ -1240,6 +1262,7 @@ "tag_created": "Etiket oluşturuldu: {tag}", "tag_feature_description": "Etiket temalarına göre gruplandırılmış fotoğraf ve videoları keşfedin", "tag_not_found_question": "Etiket bulunamadı mı? Yeni bir etiket oluşturun.", + "tag_people": "İnsanları etiketle", "tag_updated": "Etiket güncellendi: {tag}", "tagged_assets": "{count, plural, one {# dosya} other {# dosya}} etiketlendi", "tags": "Etiketler", @@ -1274,11 +1297,13 @@ "unfavorite": "Favorilerden kaldır", "unhide_person": "Kişiyi göster", "unknown": "Bilinmeyen", + "unknown_country": "Bilinmeyen ülke", "unknown_year": "Bilinmeyen YIl", "unlimited": "Sınırsız", "unlink_motion_video": "Hareketli video bağlantısını kaldır", "unlink_oauth": "OAuth bağlantısını kaldır", "unlinked_oauth_account": "Bağlantısı kaldırılmış OAuth hesabı", + "unmute_memories": "Anıların sesini aç", "unnamed_album": "İsimsiz Albüm", "unnamed_album_delete_confirmation": "Bu albümü silmek istediğinizden emin misiniz?", "unnamed_share": "İsimsiz paylaşım", @@ -1332,6 +1357,7 @@ "view_all": "Tümünü gör", "view_all_users": "Tüm kullanıcıları görüntüle", "view_in_timeline": "Zaman çizelgesinde görüntüle", + "view_link": "Bağlantıyı göster", "view_links": "Bağlantıları göster", "view_name": "Göster", "view_next_asset": "Sonraki dosyayı görüntüle", diff --git a/i18n/uk.json b/i18n/uk.json index 773b9b7c73..2815b4dac6 100644 --- a/i18n/uk.json +++ b/i18n/uk.json @@ -20,7 +20,7 @@ "add_partner": "Додати партнера", "add_path": "Додати шлях", "add_photos": "Додати знімки", - "add_to": "Додати у...", + "add_to": "Додати у…", "add_to_album": "Додати у альбом", "add_to_shared_album": "Додати у спільний альбом", "add_url": "Додати URL", @@ -41,6 +41,7 @@ "backup_settings": "Налаштування резервного копіювання", "backup_settings_description": "Керування налаштуваннями резервного копіювання бази даних", "check_all": "Перевірити все", + "cleanup": "Очищення", "cleared_jobs": "Очищені завдання для: {job}", "config_set_by_file": "Налаштовано за допомогою конфіг-файлу", "confirm_delete_library": "Ви дійсно бажаєте видалити бібліотеку \"{library}\"?", @@ -96,7 +97,7 @@ "library_scanning_enable_description": "Увімкнути періодичне сканування бібліотеки", "library_settings": "Зовнішня бібліотека", "library_settings_description": "Керування налаштуваннями зовнішніх бібліотек", - "library_tasks_description": "Виконуйте завдання бібліотеки", + "library_tasks_description": "Сканувати зовнішні бібліотеки на наявність нових і/або змінених ресурсів", "library_watching_enable_description": "Слідкуйте за змінами файлів у зовнішніх бібліотеках", "library_watching_settings": "Спостереження за бібліотекою (ЕКСПЕРИМЕНТАЛЬНЕ)", "library_watching_settings_description": "Автоматичне спостереження за зміненими файлами", @@ -131,7 +132,7 @@ "machine_learning_smart_search_description": "Пошук зображень за допомогою семантичних вбудовувань CLIP", "machine_learning_smart_search_enabled": "Увімкнути розумний пошук", "machine_learning_smart_search_enabled_description": "Якщо ця функція вимкнена, зображення не будуть кодуватися для розумного пошуку.", - "machine_learning_url_description": "URL сервера машинного навчання. Якщо надано більше одного URL, сервери будуть опитуватися по черзі, поки один з них не відповість успішно, у порядку від першого до останнього.", + "machine_learning_url_description": "URL сервера машинного навчання. Якщо надано більше одного URL, сервери будуть опитуватися по черзі, поки один з них не відповість успішно, у порядку від першого до останнього. Сервери, які не відповідають, будуть тимчасово ігноруватися, поки не з'являться онлайн.", "manage_concurrency": "Керування паралельністю завдань", "manage_log_settings": "Керування налаштуваннями журналу", "map_dark_style": "Темний стиль", @@ -147,6 +148,8 @@ "map_settings": "Мапа", "map_settings_description": "Управління налаштуваннями мапи", "map_style_description": "URL до теми мапи у форматі style.json", + "memory_cleanup_job": "Очищення пам'яті", + "memory_generate_job": "Покоління пам'яті", "metadata_extraction_job": "Витягнути метадані", "metadata_extraction_job_description": "Витягни метадані з кожного об'єкта, таку як GPS, обличчя та роздільна здатність", "metadata_faces_import_setting": "Увімкни імпорт облич", @@ -219,7 +222,7 @@ "reset_settings_to_default": "Скинути налаштування до заводських значень", "reset_settings_to_recent_saved": "Скинути налаштування до недавно збережених налаштувань", "scanning_library": "Сканування бібліотеки", - "search_jobs": "Пошук завдань...", + "search_jobs": "Пошук завдань…", "send_welcome_email": "Надіслати лист з вітанням", "server_external_domain_settings": "Зовнішній домен", "server_external_domain_settings_description": "Домен для публічних загальнодоступних посилань, включаючи http(s)://", @@ -240,7 +243,7 @@ "storage_template_hash_verification_enabled_description": "Увімкнути перевірку хеша. Не вимикайте це, якщо ви не впевнені в наслідках", "storage_template_migration": "Міграція шаблонів сховища", "storage_template_migration_description": "Застосувати поточний {template} до раніше завантажених ресурсів", - "storage_template_migration_info": "Зміни шаблону будуть застосовуватися тільки до нових ресурсів. Для ретроспективного застосування шаблону до ресурсів, завантажених раніше, запустіть {job}.", + "storage_template_migration_info": "Шаблон зберігання конвертуватиме всі розширення у нижній регістр. Зміни шаблону застосовуватимуться лише до нових ресурсів. Щоб застосувати шаблон до раніше завантажених ресурсів, запустіть {job}.", "storage_template_migration_job": "Завдання міграції шаблону зберігання", "storage_template_more_details": "Для отримання детальнішої інформації про цю функцію, звертайтесь до Шаблону зберігання та його наслідків", "storage_template_onboarding_description": "Після увімкнення ця функція автоматично організовуватиме файли за користувацьким шаблоном. З міркувань стабільності функцію за замовчуванням вимкнено. Докладніша інформація доступна в документації.", @@ -391,6 +394,7 @@ "allow_edits": "Дозволити редагування", "allow_public_user_to_download": "Дозволити публічному користувачеві завантажувати файли", "allow_public_user_to_upload": "Дозволити публічним користувачам завантажувати", + "alt_text_qr_code": "Зображення QR-коду", "anti_clockwise": "Проти годинникової стрілки", "api_key": "Ключ API", "api_key_description": "Це значення буде показане лише один раз. Будь ласка, обов'язково скопіюйте його перед закриттям вікна.", @@ -406,17 +410,17 @@ "are_these_the_same_person": "Це та сама людина?", "are_you_sure_to_do_this": "Ви впевнені, що хочете це зробити?", "asset_added_to_album": "Додано до альбому", - "asset_adding_to_album": "Додати до альбому...", + "asset_adding_to_album": "Додати до альбому…", "asset_description_updated": "Оновлено опис ресурсу", "asset_filename_is_offline": "Ресурс {filename} відключено", "asset_has_unassigned_faces": "Є нерозпізнані обличчя", - "asset_hashing": "Хешування...", + "asset_hashing": "Хешування…", "asset_offline": "Актив вимкнено", "asset_offline_description": "Цей зовнішній актив більше не знайдено на диску. Будь ласка, зверніться до адміністратора Immich за допомогою.", "asset_skipped": "Пропущено", "asset_skipped_in_trash": "У смітнику", "asset_uploaded": "Завантажено", - "asset_uploading": "Завантаження...", + "asset_uploading": "Завантаження…", "assets": "елементи", "assets_added_count": "Додано {count, plural, one {# ресурс} few {# ресурси} other {# ресурсів}}", "assets_added_to_album_count": "Додано {count, plural, one {# ресурс} few {# ресурси} other {# ресурсів}} до альбому", @@ -481,6 +485,7 @@ "comments_are_disabled": "Коментарі вимкнено", "confirm": "Підтвердіть", "confirm_admin_password": "Підтвердити пароль адміністратора", + "confirm_delete_face": "Ви впевнені, що хочете видалити обличчя {name} з активу?", "confirm_delete_shared_link": "Ви впевнені, що хочете видалити це спільне посилання?", "confirm_keep_this_delete_others": "Усі інші ресурси в стеку буде видалено, окрім цього ресурсу. Ви впевнені, що хочете продовжити?", "confirm_password": "Підтвердити пароль", @@ -533,6 +538,7 @@ "delete_album": "Видалити альбом", "delete_api_key_prompt": "Ви впевнені, що хочете видалити цей ключ API?", "delete_duplicates_confirmation": "Ви впевнені, що хочете назавжди видалити ці дублікати?", + "delete_face": "Видалити обличчя", "delete_key": "Видалити ключ", "delete_library": "Видалити бібліотеку", "delete_link": "Видалити посилання", @@ -600,6 +606,7 @@ "enabled": "Увімкнено", "end_date": "Дата завершення", "error": "Помилка", + "error_delete_face": "Помилка при видаленні обличчя з активу", "error_loading_image": "Помилка завантаження зображення", "error_title": "Помилка: щось пішло не так", "errors": { @@ -766,8 +773,10 @@ "go_to_folder": "Перейти до папки", "go_to_search": "Перейти до пошуку", "group_albums_by": "Групувати альбоми за...", + "group_country": "Групувати за країною", "group_no": "Без групування", "group_owner": "За власником", + "group_places_by": "Групувати місця за...", "group_year": "За роком", "has_quota": "Квота", "hi_user": "Привіт {name} ({email})", @@ -800,6 +809,7 @@ "include_shared_albums": "Включити спільні альбоми", "include_shared_partner_assets": "Включайте спільні партнерські активи", "individual_share": "Індивідуальний доступ", + "individual_shares": "Окремі спільні доступи", "info": "Інформація", "interval": { "day_at_onepm": "Щодня о 13:00", @@ -822,6 +832,7 @@ "latest_version": "Остання версія", "latitude": "Широта", "leave": "Покинути", + "lens_model": "Модель об'єктива", "let_others_respond": "Дозволити іншим відповідати", "level": "Рівень", "library": "Бібліотека", @@ -880,6 +891,7 @@ "month": "Місяць", "more": "Більше", "moved_to_trash": "Перенесено до смітника", + "mute_memories": "Приглушити спогади", "my_albums": "Мої альбоми", "name": "Ім'я", "name_or_nickname": "Ім'я або псевдонім", @@ -975,6 +987,7 @@ "permanently_deleted_asset": "Видалити назавжди", "permanently_deleted_assets_count": "Видалено остаточно {count, plural, one {# ресурс} few {# ресурси} many {# ресурсів} other {# ресурсів}}", "person": "Людина", + "person_birthdate": "Народився {date}", "person_hidden": "{name}{hidden, select, true { (приховано)} other {}}", "photo_shared_all_users": "Виглядає так, що ви поділилися своїми фотографіями з усіма користувачами або у вас немає жодного користувача, з яким можна поділитися.", "photos": "Знімки", @@ -984,6 +997,7 @@ "pick_a_location": "Виберіть місце розташування", "place": "Місце", "places": "Місця", + "places_count": "{count, plural, one {{count, number} Місце} other {{count, number} Місця}}", "play": "Відтворити", "play_memories": "Відтворити спогади", "play_motion_photo": "Відтворювати рухомі фото", @@ -1071,6 +1085,8 @@ "removed_from_archive": "Видалено з архіву", "removed_from_favorites": "Видалено з обраного", "removed_from_favorites_count": "{count, plural, other {Видалено #}} з обраних", + "removed_memory": "Видалена пам'ять", + "removed_photo_from_memory": "Фото видалене з пам'яті", "removed_tagged_assets": "Видалено тег із {count, plural, one {# активу} other {# активів}}", "rename": "Перейменувати", "repair": "Ремонт", @@ -1079,6 +1095,7 @@ "repository": "Репозиторій", "require_password": "Вимагати пароль", "require_user_to_change_password_on_first_login": "Вимагати від користувача змінювати пароль при першому вході", + "rescan": "Пересканування", "reset": "Скидання", "reset_password": "Скинути пароль", "reset_people_visibility": "Відновити видимість людей", @@ -1107,18 +1124,22 @@ "search": "Пошук", "search_albums": "Шукати альбоми", "search_by_context": "Пошук за контекстом", + "search_by_description": "Пошук за описом", + "search_by_description_example": "Похідний день у Сапі", "search_by_filename": "Пошук за назвою або розширенням файлу", "search_by_filename_example": "Наприклад, IMG_1234.JPG або PNG", "search_camera_make": "Пошук виробника камери...", "search_camera_model": "Пошук моделі камери...", "search_city": "Пошук міста...", "search_country": "Пошук країни...", + "search_for": "Шукати для", "search_for_existing_person": "Пошук існуючої особи", "search_no_people": "Немає людей", "search_no_people_named": "Немає осіб з іменем \"{name}\"", "search_options": "Опції пошуку", "search_people": "Шукати людей", "search_places": "Пошук місць", + "search_rating": "Пошук за рейтингом...", "search_settings": "Налаштування пошуку", "search_state": "Пошук регіону...", "search_tags": "Пошук тегів...", @@ -1165,6 +1186,7 @@ "shared_from_partner": "Фото від {partner}", "shared_link_options": "Опції спільних посилань", "shared_links": "Спільні посилання", + "shared_links_description": "Діліться фото та відео за посиланням", "shared_photos_and_videos_count": "{assetCount, plural, other {# спільні фотографії та відео.}}", "shared_with_partner": "Спільно з {partner}", "sharing": "Спільні", @@ -1187,6 +1209,7 @@ "show_person_options": "Показати параметри людини", "show_progress_bar": "Показати індикатор прогресу", "show_search_options": "Показати параметри пошуку", + "show_shared_links": "Показати спільні посилання", "show_slideshow_transition": "Показати перехід слайд-шоу", "show_supporter_badge": "Значок підтримки", "show_supporter_badge_description": "Показати значок підтримки", @@ -1240,6 +1263,7 @@ "tag_created": "Створено тег: {tag}", "tag_feature_description": "Перегляд фотографій та відео, згрупованих за логічними темами тегів", "tag_not_found_question": "Не вдається знайти тег? Створити новий тег.", + "tag_people": "Тег людей", "tag_updated": "Оновлено тег: {tag}", "tagged_assets": "Позначено тегом {count, plural, one {# актив} other {# активи}}", "tags": "Теги", @@ -1274,11 +1298,13 @@ "unfavorite": "Видалити з улюблених", "unhide_person": "Розкрити особу", "unknown": "Невідомо", + "unknown_country": "Невідома країна", "unknown_year": "Невідомий рік", "unlimited": "Без обмежень", "unlink_motion_video": "Від'єднати рухоме відео", "unlink_oauth": "Від'єднайте OAuth", "unlinked_oauth_account": "Відключити акаунт OAuth", + "unmute_memories": "Увімкнути звук спогадів", "unnamed_album": "Альбом без назви", "unnamed_album_delete_confirmation": "Ви впевнені, що бажаєте видалити цей альбом?", "unnamed_share": "Спільний доступ без назви", @@ -1332,6 +1358,7 @@ "view_all": "Переглянути усі", "view_all_users": "Переглянути всіх користувачів", "view_in_timeline": "Переглянути в хронології", + "view_link": "Переглянути посилання", "view_links": "Переглянути посилання", "view_name": "Переглянути", "view_next_asset": "Переглянути наступний ресурс", diff --git a/i18n/ur.json b/i18n/ur.json index 0967ef424b..296a8eec25 100644 --- a/i18n/ur.json +++ b/i18n/ur.json @@ -1 +1,3 @@ -{} +{ + "about": "متعلق" +} diff --git a/i18n/vi.json b/i18n/vi.json index ca393b2b25..d84a588614 100644 --- a/i18n/vi.json +++ b/i18n/vi.json @@ -284,7 +284,7 @@ "transcoding_max_b_frames": "Số B-frame tối đa", "transcoding_max_b_frames_description": "Giá trị cao hơn cải thiện hiệu quả nén, nhưng làm chậm mã hóa. Có thể không tương thích với tăng tốc phần cứng trên các thiết bị cũ. Giá trị 0 để tắt B-frames, trong khi giá trị -1 để tự động thiết lập giá trị này.", "transcoding_max_bitrate": "Bitrate tối đa", - "transcoding_max_bitrate_description": "Cài đặt giới hạn bitrate tối đa có thể giúp kích thước video dễ dự đoán hơn, với một chút hy sinh về chất lượng. Ở độ phân giải 720p, giá trị điển hình là 2600k cho VP9 hoặc HEVC, hoặc 4500k cho H.264. Nếu đặt thành 0, chức năng này sẽ bị vô hiệu hóa.", + "transcoding_max_bitrate_description": "Cài đặt giới hạn bitrate tối đa có thể giúp kích thước video dễ dự đoán hơn, với một chút hy sinh về chất lượng. Ở độ phân giải 720p, giá trị điển hình là 2600 kbit/s cho VP9 hoặc HEVC, hoặc 4500 kbit/s cho H.264. Nếu đặt thành 0, chức năng này sẽ bị vô hiệu hóa.", "transcoding_max_keyframe_interval": "Khoảng cách tối đa giữa các khung hình chính", "transcoding_max_keyframe_interval_description": "Thiết lập khoảng thời gian tối đa giữa các khung hình chính. Giá trị thấp hơn làm giảm hiệu suất nén, nhưng cải thiện thời gian tìm kiếm và có thể cải thiện chất lượng trong các cảnh có chuyển động nhanh. Giá trị 0 để tự động thiết lập giá trị này.", "transcoding_optimal_description": "Video có độ phân giải cao hơn mục tiêu hoặc không ở định dạng được chấp nhận", @@ -1081,6 +1081,8 @@ "search": "Tìm kiếm", "search_albums": "Tìm kiếm album", "search_by_context": "Tìm kiếm theo ngữ cảnh", + "search_by_description": "Tìm kiếm theo nội dung", + "search_by_description_example": "Dạo chơi Sa Pa", "search_by_filename": "Tìm kiếm theo tên hoặc phần mở rộng tập tin", "search_by_filename_example": "Ví dụ: IMG_1234.JPG hoặc PNG", "search_camera_make": "Tìm kiếm thương hiệu máy ảnh...", @@ -1315,4 +1317,4 @@ "yes": "Có", "you_dont_have_any_shared_links": "Bạn không có liên kết chia sẻ nào", "zoom_image": "Thu phóng ảnh" -} +} \ No newline at end of file diff --git a/i18n/zh_Hant.json b/i18n/zh_Hant.json index 51705d11e6..bcd543c78a 100644 --- a/i18n/zh_Hant.json +++ b/i18n/zh_Hant.json @@ -2,7 +2,7 @@ "about": "關於", "account": "帳號", "account_settings": "帳號設定", - "acknowledge": "收到", + "acknowledge": "明白", "action": "操作", "actions": "操作", "active": "處理中", @@ -41,6 +41,7 @@ "backup_settings": "備份設定", "backup_settings_description": "管理資料庫備份設定", "check_all": "全選", + "cleanup": "清理", "cleared_jobs": "已刪除「{job}」任務", "config_set_by_file": "已透過設定檔更新設定", "confirm_delete_library": "確定要刪除 {library} 相簿嗎?", @@ -70,15 +71,15 @@ "image_prefer_wide_gamut": "偏好廣色域", "image_prefer_wide_gamut_setting_description": "使用 Display P3 來製作縮圖。這可以更好地保留廣色域圖片的鮮豔度,但在舊版瀏覽器或舊設備上,圖片可能會顯示不同。sRGB 圖片會維持 sRGB 以避免顏色變化。", "image_preview_description": "刪除中等尺寸圖片的詳細資料,當選擇看指定項目和機器學習時使用", - "image_preview_quality_description": "預覽品質爲 1 ~ 100。數值越大品質越高,但會產生較大的檔案,且可能降低應用程式的響應速度。而數值較小可能會影響機器學習品質。", + "image_preview_quality_description": "預覽品質為 1 ~ 100。數值越大品質越高,但會產生較大的檔案,且可能降低應用程式的回應速度。而數值較小可能會影響機器學習品質。", "image_preview_title": "預覽設定", "image_quality": "品質", "image_resolution": "解析度", - "image_resolution_description": "較高的解析度可以保留更多細節,但編碼時間較長,檔案較大且可能降低應用程式的響應速度。", + "image_resolution_description": "較高的解析度可以保留更多細節,但編碼時間較長,檔案較大且可能降低應用程式的回應速度。", "image_settings": "圖片設定", "image_settings_description": "管理產生圖片的品質和解析度", "image_thumbnail_description": "刪除縮圖的詳細資料,在快速瀏覽重要時間軸時或大量照片時使用", - "image_thumbnail_quality_description": "縮圖品質爲 1 ~ 100。數值越大品質越高,但會產生較大的檔案,且可能降低應用程式的響應速度。", + "image_thumbnail_quality_description": "縮圖品質為 1 ~ 100。數值越大品質越高,但會產生較大的檔案,且可能降低應用程式的回應速度。", "image_thumbnail_title": "縮圖設定", "job_concurrency": "{job}並行", "job_created": "已建立作業", @@ -96,7 +97,7 @@ "library_scanning_enable_description": "啟用圖庫定期掃描", "library_settings": "外部圖庫", "library_settings_description": "管理外部圖庫設定", - "library_tasks_description": "執行圖庫任務", + "library_tasks_description": "掃描外部資料庫以尋找新增或更改的資源", "library_watching_enable_description": "監控外部圖庫的檔案變化", "library_watching_settings": "圖庫監控(實驗中)", "library_watching_settings_description": "自動監控檔案的變化", @@ -104,7 +105,7 @@ "logging_level_description": "啟用時的記錄層級。", "logging_settings": "記錄檔", "machine_learning_clip_model": "CLIP 模型", - "machine_learning_clip_model_description": "這裏有份 CLIP 模型名單。註:更換模型後須對所有圖片重新執行「智慧搜尋」作業。", + "machine_learning_clip_model_description": "這裡有份 CLIP 模型名單。註:更換模型後須對所有圖片重新執行「智慧搜尋」作業。", "machine_learning_duplicate_detection": "重複項目偵測", "machine_learning_duplicate_detection_enabled": "啟用重複項目偵測", "machine_learning_duplicate_detection_enabled_description": "即使停用,完全一樣的素材仍會被忽略。", @@ -114,24 +115,24 @@ "machine_learning_facial_recognition": "臉部辨識", "machine_learning_facial_recognition_description": "偵測、認出並對圖片中的臉孔分組", "machine_learning_facial_recognition_model": "人臉辨識模型", - "machine_learning_facial_recognition_model_description": "模型順序由大至小排列。大的模型較慢且使用較多記憶體,但成效較嘉。更換模型後須對所有影像重新執行「人臉辨識」。", + "machine_learning_facial_recognition_model_description": "模型順序由大至小排列。大的模型較慢且使用較多記憶體,但成效較佳。更換模型後需對所有影像重新執行「人臉辨識」。", "machine_learning_facial_recognition_setting": "啟用人臉辨識", "machine_learning_facial_recognition_setting_description": "若停用,影像將不會產生人臉特徵編碼,從而「探索」頁面不會有「人物」功能。", - "machine_learning_max_detection_distance": "針測距離上限", + "machine_learning_max_detection_distance": "偵測距離上限", "machine_learning_max_detection_distance_description": "若兩張影像間的距離小於此將被判斷為相同,範圍為 0.001-0.1。數值越高能偵測到越多重複,但也更有可能誤判。", "machine_learning_max_recognition_distance": "分辨距離上限", "machine_learning_max_recognition_distance_description": "若兩張人臉間的距離小於此將被判斷為相同人物,範圍為 0-2。數值降低能減少兩人被混在一起的可能性,數值提升能減少同一人被當作不同臉的可能性。由於合並比拆分容易,建議將數值調小。", "machine_learning_min_detection_score": "最低檢測分數", "machine_learning_min_detection_score_description": "最低信任分辨率,從0到1。低值會偵測更多的面孔,但可能導致誤報。", - "machine_learning_min_recognized_faces": "最少被認出的臉孔", - "machine_learning_min_recognized_faces_description": "要創建一個人的最低認可面數。 增加此項數目使面部識別更為準確,但以增加可能不把面孔識別於任何人的機會為代價.", + "machine_learning_min_recognized_faces": "最低臉部辨識數量", + "machine_learning_min_recognized_faces_description": "歸納出新人物的最低臉部數量。調高此數值可以讓臉部辨識更準確,但也可能會讓較少出現的臉孔無法被歸納到人物清單。", "machine_learning_settings": "機器學習設定", "machine_learning_settings_description": "管理機器學習的功能和設定", "machine_learning_smart_search": "智慧搜尋", "machine_learning_smart_search_description": "使用 CLIP 嵌入進行語義圖像搜尋", "machine_learning_smart_search_enabled": "啟用智慧搜尋", "machine_learning_smart_search_enabled_description": "如果停用,圖片將不會被編碼以進行智能搜尋。", - "machine_learning_url_description": "機器學習伺服器的網址,如果提供多個 URL,則將按依序嘗試連接每個伺服器,直到有一個伺服器成功回應為止。", + "machine_learning_url_description": "機器學習伺服器的 URL。如果提供多個 URL,將依序嘗試每個伺服器,直到有一個成功回應,從第一個到最後一個。未回應的伺服器將暫時被忽略,直到它們恢復線上。", "manage_concurrency": "管理並行", "manage_log_settings": "管理日誌設定", "map_dark_style": "深色樣式", @@ -147,7 +148,9 @@ "map_settings": "地圖", "map_settings_description": "管理地圖設定", "map_style_description": "地圖主題(style.json)的網址", - "metadata_extraction_job": "擷取元資料", + "memory_cleanup_job": "記憶體清理", + "memory_generate_job": "記憶體生成", + "metadata_extraction_job": "擷取詮釋資料", "metadata_extraction_job_description": "擷取所有檔案的 GPS、臉孔、解析度等原始詳細資料", "metadata_faces_import_setting": "啟用臉孔匯入", "metadata_faces_import_setting_description": "從圖片的 EXIF 資料和側接檔案匯入臉孔", @@ -188,17 +191,17 @@ "oauth_mobile_redirect_uri": "移動端重定向 URI", "oauth_mobile_redirect_uri_override": "移動端重定向 URI 覆蓋", "oauth_mobile_redirect_uri_override_description": "當 OAuth 提供者不允許使用行動 URI(如「'{callback}'」)時啟用", - "oauth_profile_signing_algorithm": "用戶檔簽名算法", - "oauth_profile_signing_algorithm_description": "用於簽署用戶檔的算法。", + "oauth_profile_signing_algorithm": "設定檔簽章演算法", + "oauth_profile_signing_algorithm_description": "用於簽署使用者設定檔的演算法。", "oauth_scope": "範圍", "oauth_settings": "OAuth", "oauth_settings_description": "管理 OAuth 登入設定", "oauth_settings_more_details": "欲瞭解此功能,請參閱說明書。", - "oauth_signing_algorithm": "簽名算法", + "oauth_signing_algorithm": "簽章演算法", "oauth_storage_label_claim": "儲存標籤宣告", - "oauth_storage_label_claim_description": "自動將使用者的儲存標籤定爲此宣告之值。", + "oauth_storage_label_claim_description": "自動將使用者的儲存標籤定為此宣告之值。", "oauth_storage_quota_claim": "儲存配額宣告", - "oauth_storage_quota_claim_description": "自動將使用者的儲存配額定爲此宣告之值。", + "oauth_storage_quota_claim_description": "自動將使用者的儲存配額定為此宣告之值。", "oauth_storage_quota_default": "預設儲存配額(GiB)", "oauth_storage_quota_default_description": "未宣告時所使用的配額(單位:GiB)(輸入 0 表示不限制配額)。", "offline_paths": "失效路徑", @@ -211,7 +214,7 @@ "quota_size_gib": "配額(GiB)", "refreshing_all_libraries": "正在重新整理所有圖庫", "registration": "管理者註冊", - "registration_description": "由於您是本系統的首位使用者,因此將您指派爲負責管理本系統的管理者,其他使用者須由您協助建立帳號。", + "registration_description": "由於您是本系統的首位使用者,因此將您指派為負責管理本系統的管理者,其他使用者須由您協助建立帳號。", "repair_all": "全部糾正", "repair_matched_items": "有 {count, plural, other {# 個項目相符}}", "repaired_items": "已糾正 {count, plural, other {# 個項目}}", @@ -237,7 +240,7 @@ "storage_template_date_time_sample": "時間樣式 {date}", "storage_template_enable_description": "啟用存儲模板引擎", "storage_template_hash_verification_enabled": "散列函数驗證已啟用", - "storage_template_hash_verification_enabled_description": "啟用散列函数驗證,除非您知道自己正在做的事,否則請勿禁用此功能", + "storage_template_hash_verification_enabled_description": "啟用散列函数驗證,除非您很清楚地知道這個選項的作用,否則請勿停用此功能", "storage_template_migration": "存儲模板遷移", "storage_template_migration_description": "將當前的 {template} 應用於先前上傳的檔案", "storage_template_migration_info": "模板更改僅適用於新檔案。若要追溯應用模板至先前上傳的檔案,請運行 {job}。", @@ -266,47 +269,47 @@ "theme_settings_description": "自訂 Immich 的網頁界面", "these_files_matched_by_checksum": "這些檔案的核對和(Checksum)是相符的", "thumbnail_generation_job": "產生縮圖", - "thumbnail_generation_job_description": "爲每個檔案產生大、小及模糊縮圖,也爲每位人物產生縮圖", + "thumbnail_generation_job_description": "為每個檔案產生大、小及模糊縮圖,也為每位人物產生縮圖", "transcoding_acceleration_api": "加速 API", - "transcoding_acceleration_api_description": "該 API 將用您的設備加速轉碼。設置是“盡力而為”:如果失敗,它將退回到軟件轉碼。VP9 轉碼是否可行取決於您的硬件。", + "transcoding_acceleration_api_description": "該 API 將用您的設備加速轉碼。設置是“盡力而為”:如果失敗,它將退回到軟體轉碼。VP9 轉碼是否可行取決於您的硬體。", "transcoding_acceleration_nvenc": "NVENC(需要 NVIDIA GPU)", "transcoding_acceleration_qsv": "快速同步(需要第七代或高於第七代的 Intel CPU)", "transcoding_acceleration_rkmpp": "RKMPP(僅適用於 Rockchip SoC)", "transcoding_acceleration_vaapi": "VAAPI", - "transcoding_accepted_audio_codecs": "接受的音頻編解碼器", - "transcoding_accepted_audio_codecs_description": "選擇不需要轉碼的音頻編解碼器。僅用於某些轉碼策略。", + "transcoding_accepted_audio_codecs": "支援的音訊編碼器", + "transcoding_accepted_audio_codecs_description": "選擇不需要轉碼的音訊編碼格式。僅適用於某些轉碼策略。", "transcoding_accepted_containers": "接受的容器格式", "transcoding_accepted_containers_description": "選擇不需要重新封裝為 MP4 的容器格式。僅用於某些轉碼策略。", "transcoding_accepted_video_codecs": "支援的影片編碼器", - "transcoding_accepted_video_codecs_description": "選擇不需要轉碼的視頻編解碼器。僅用於某些轉碼策略。", + "transcoding_accepted_video_codecs_description": "選擇不需要轉碼的視訊編碼格式。僅適用於某些轉碼策略。", "transcoding_advanced_options_description": "大多數使用者不需要更改的選項", - "transcoding_audio_codec": "音頻編解碼器", - "transcoding_audio_codec_description": "Opus 是音質最高的選擇,但會與舊設備或軟件有較低的兼容性。", + "transcoding_audio_codec": "音訊編碼器", + "transcoding_audio_codec_description": "Opus 是音質最好的選項,但與舊設備或舊版軟體的相容性較低。", "transcoding_bitrate_description": "高於最大位元速率或格式不被支援的影片", "transcoding_codecs_learn_more": "欲瞭解此處使用的術語,請參閱 FFmpeg 說明書中的 H.264 編解碼器HEVC 編解碼器VP9 編解碼器。", - "transcoding_constant_quality_mode": "恆定質量模式", - "transcoding_constant_quality_mode_description": "ICQ 比 CQP 更好,但某些硬件加速設備不支持此模式。設置此選項時,會在使用基於質量的編碼時偏好指定的模式。由於 NVENC 不支持 ICQ,此選項對其無效。", + "transcoding_constant_quality_mode": "固定品質模式", + "transcoding_constant_quality_mode_description": "ICQ 比 CQP 更好,但某些硬體加速設備不支援此模式。設定此選項時,會在使用基於品質的編碼時偏好指定的模式。此選項對 NVENC 無效,因為 NVENC 不支援 ICQ。", "transcoding_constant_rate_factor": "恆定速率因子(-crf)", - "transcoding_constant_rate_factor_description": "視頻質量級別。典型值為 H.264 的 23、HEVC 的 28、VP9 的 31 和 AV1 的 35。數值越低,質量越高,但會產生較大的文件。", + "transcoding_constant_rate_factor_description": "視訊品質等級。典型值為 H.264 的 23、HEVC 的 28、VP9 的 31 和 AV1 的 35。數值越低,品質越好,但會產生較大的檔案。", "transcoding_disabled_description": "不轉碼影片,可能會讓某些客戶端無法正常播放", "transcoding_encoding_options": "編碼選項", "transcoding_encoding_options_description": "設定編碼影片的編解碼器、解析度、品質和其他選項", "transcoding_hardware_acceleration": "硬體加速", - "transcoding_hardware_acceleration_description": "實驗性功能;速度更快,但在相同比特率下質量較低", + "transcoding_hardware_acceleration_description": "實驗性功能;速度更快,但在相同位元速率下品質較差", "transcoding_hardware_decoding": "硬體解碼", "transcoding_hardware_decoding_setting_description": "不只加速編碼,還啟用端對端加速。可能不支援某些影片。", "transcoding_hevc_codec": "HEVC 編解碼器", "transcoding_max_b_frames": "最大 B 幀數", - "transcoding_max_b_frames_description": "更高的值可以提高壓縮效率,但會降低編碼速度。在舊設備上可能不兼容硬件加速。0 表示禁用 B 幀,而 -1 則會自動設置此值。", + "transcoding_max_b_frames_description": "較高的數值可提升壓縮效率,但會降低編碼速度。可能與較舊設備的硬體加速不相容。設定為 0 時會停用 B-frames,而 -1 則會自動設定此數值。", "transcoding_max_bitrate": "最大位元速率", - "transcoding_max_bitrate_description": "設置最大比特率可以使文件大小更具可預測性,但會稍微降低質量。在 720p 分辨率下,典型值為 VP9 或 HEVC 的 2600k,或 H.264 的 4500k。設置為 0 則禁用此功能。", + "transcoding_max_bitrate_description": "設定最大位元速率可以使檔案大小更具可預測性,但會稍微降低品質。在 720p 解析度下,典型值為 VP9 或 HEVC 的 2600 kbit/s,或 H.264 的 4500 kbit/s。設置為 0 停用此功能。", "transcoding_max_keyframe_interval": "最大關鍵幀間隔", - "transcoding_max_keyframe_interval_description": "設置關鍵幀之間的最大幀距。較低的值會降低壓縮效率,但可以改善尋找時間,並可能改善快速運動場景中的質量。0 會自動設置此值。", + "transcoding_max_keyframe_interval_description": "設置關鍵幀之間的最大幀距。較低的值會降低壓縮效率,但可以改善搜尋時間,並有可能會改善快速變動場景的品質。0 會自動設置此值。", "transcoding_optimal_description": "高於目標解析度或格式不被支援的影片", "transcoding_policy": "轉碼策略", "transcoding_policy_description": "設定影片進行轉碼的條件", - "transcoding_preferred_hardware_device": "首選硬件設備", - "transcoding_preferred_hardware_device_description": "僅適用於 VAAPI 和 QSV。設置用於硬件轉碼的 DRI 節點。", + "transcoding_preferred_hardware_device": "首選硬體設備", + "transcoding_preferred_hardware_device_description": "僅適用於 VAAPI 和 QSV。設定用於硬體轉碼的 DRI 節點。", "transcoding_preset_preset": "預設值(-preset)", "transcoding_preset_preset_description": "壓縮速度。在針對特定位元速率時,較慢的預設值會減少檔案大小並提高品質。VP9 會忽略高於「faster」的速度。", "transcoding_reference_frames": "參考幀數", @@ -315,19 +318,19 @@ "transcoding_settings": "影片轉碼", "transcoding_settings_description": "管理影片的解析度和編碼資訊", "transcoding_target_resolution": "目標解析度", - "transcoding_target_resolution_description": "較高的解析度可以保留更多細節,但編碼時間較長,檔案也較大,且可能降低應用程式的響應速度。", + "transcoding_target_resolution_description": "較高的解析度可以保留更多細節,但編碼時間較長,檔案也較大,且可能降低應用程式的回應速度。", "transcoding_temporal_aq": "時間自適應量化(Temporal AQ)", - "transcoding_temporal_aq_description": "僅適用於 NVENC。提高高細節、低運動場景的質量。可能與舊設備不兼容。", - "transcoding_threads": "線程數量", + "transcoding_temporal_aq_description": "僅適用於 NVENC,可提升高細節、低動態場景的畫質。可能與較舊的設備不相容。", + "transcoding_threads": "執行緒數量", "transcoding_threads_description": "較高的值會加快編碼速度,但會減少伺服器在運行過程中處理其他任務的空間。此值不應超過 CPU 核心數。設置為 0 可以最大化利用率。", "transcoding_tone_mapping": "色調映射", - "transcoding_tone_mapping_description": "在將 HDR 視頻轉換為 SDR 時,嘗試保留其外觀。每種算法在顏色、細節和亮度方面都有不同的權衡。Hable 保留細節,Mobius 保留顏色,Reinhard 保留亮度。", + "transcoding_tone_mapping_description": "在將 HDR 影片轉換為 SDR 時,盡量維持原始觀感。每種演算法在色彩、細節和亮度方面都有不同的權衡。Hable 保留細節,Mobius 保留色彩,Reinhard 保留亮度。", "transcoding_transcode_policy": "轉碼策略", - "transcoding_transcode_policy_description": "視頻何時應進行轉碼的策略。HDR 視頻將始終進行轉碼(除非禁用轉碼)。", - "transcoding_two_pass_encoding": "雙通道編碼", - "transcoding_two_pass_encoding_setting_description": "使用雙通道編碼以產生更高質量的編碼視頻。當啟用最大比特率時(對 H.264 和 HEVC 有效),此模式使用基於最大比特率的比特率範圍,並忽略 CRF。對於 VP9,如果禁用最大比特率,可以使用 CRF。", - "transcoding_video_codec": "視頻編解碼器", - "transcoding_video_codec_description": "VP9 具有高效能和網頁兼容性,但轉碼時間較長。HEVC 性能相似,但網頁兼容性較低。H.264 兼容性廣泛且轉碼速度快,但生成的文件較大。AV1 是最有效的編解碼器,但在舊設備上支持度不足。", + "transcoding_transcode_policy_description": "影片何時應進行轉碼的策略。HDR 影片一定會轉碼(除非停用轉碼)。", + "transcoding_two_pass_encoding": "兩階段編碼", + "transcoding_two_pass_encoding_setting_description": "使用兩階段編碼來產生品質更佳的編碼影片。當啟用最大位元速率時(H.264 和 HEVC 必須啟用此選項才能運作),此模式會以最大位元速率來調整位元速率範圍,並忽略 CRF。對於 VP9,如果停用最大位元速率,可以使用 CRF。", + "transcoding_video_codec": "視訊編碼器", + "transcoding_video_codec_description": "VP9 具有高效能且相容於網頁,但轉碼時間較長。HEVC 的效能相近,但網頁相容性較低。H.264 具有廣泛的相容性且轉碼速度快,但產生的檔案較大。AV1 是目前效率最好的編解碼器,但較舊設備不支援。", "trash_enabled_description": "啟用垃圾桶功能", "trash_number_of_days": "日數", "trash_number_of_days_description": "永久刪除之前,將檔案保留在垃圾桶中的日數", @@ -339,8 +342,8 @@ "user_delete_delay": "{user} 的帳號和項目將於 {delay, plural, other {# 天}}後永久刪除。", "user_delete_delay_settings": "延後刪除", "user_delete_delay_settings_description": "移除後,永久刪除使用者帳號和檔案的天數。使用者刪除作業會在午夜檢查是否有可以刪除的使用者。變更這項設定後,會在下次執行時檢查。", - "user_delete_immediately": "{user} 的帳戶和資產將被立即排隊進行永久刪除。", - "user_delete_immediately_checkbox": "將用戶和資產排隊進行立即刪除", + "user_delete_immediately": "{user} 的帳號和項目將立即永久刪除。", + "user_delete_immediately_checkbox": "將使用者和項目立即刪除", "user_management": "使用者管理", "user_password_has_been_reset": "使用者密碼已重設:", "user_password_reset_description": "請提供使用者臨時密碼,並告知下次登入時需要更改密碼。", @@ -386,15 +389,16 @@ "all": "全部", "all_albums": "所有相簿", "all_people": "所有人", - "all_videos": "所有視頻", + "all_videos": "所有影片", "allow_dark_mode": "允許深色模式", "allow_edits": "允許編輯", "allow_public_user_to_download": "開放給使用者下載", "allow_public_user_to_upload": "開放讓使用者上傳", + "alt_text_qr_code": "QR 碼圖片", "anti_clockwise": "逆時針", "api_key": "API 金鑰", "api_key_description": "此值僅顯示一次。請確保在關閉窗口之前複製它。", - "api_key_empty": "您的 API 金鑰名稱不能爲空", + "api_key_empty": "您的 API 金鑰名稱不能為空", "api_keys": "API 金鑰", "app_settings": "應用程式設定", "appears_in": "出現在", @@ -410,7 +414,7 @@ "asset_description_updated": "檔案描述已更新", "asset_filename_is_offline": "檔案 {filename} 離線了", "asset_has_unassigned_faces": "檔案中有未指定的臉孔", - "asset_hashing": "Hashing中...", + "asset_hashing": "計算雜湊值…", "asset_offline": "檔案離線", "asset_offline_description": "磁碟中找不到此外部檔案。請向您的 Immich 管理員尋求協助。", "asset_skipped": "已略過", @@ -418,7 +422,7 @@ "asset_uploaded": "已上傳", "asset_uploading": "上傳中…", "assets": "檔案", - "assets_added_count": "已添加 {count, plural, one {# 個資產} other {# 個資產}}", + "assets_added_count": "已新增 {count, plural, one {# 個項目} other {# 個項目}}", "assets_added_to_album_count": "已將 {count, plural, other {# 個檔案}}加入相簿", "assets_added_to_name_count": "已將 {count, plural, other {# 個檔案}}加入{hasName, select, true {{name}} other {新相簿}}", "assets_count": "{count, plural, one {# 個檔案} other {# 個檔案}}", @@ -430,7 +434,7 @@ "assets_trashed_count": "已丟掉 {count, plural, other {# 個檔案}}", "assets_were_part_of_album_count": "{count, plural, one {檔案已} other {檔案已}} 是相冊的一部分", "authorized_devices": "授權裝置", - "back": "后退", + "back": "返回", "back_close_deselect": "返回、關閉及取消選取", "backward": "倒轉", "birthdate_saved": "出生日期儲存成功", @@ -475,12 +479,13 @@ "collapse_all": "全部折疊", "color": "顏色", "color_theme": "色彩主題", - "comment_deleted": "評論已刪除", - "comment_options": "評論選項", - "comments_and_likes": "評論與讚好", - "comments_are_disabled": "評論已禁用", + "comment_deleted": "留言已刪除", + "comment_options": "留言選項", + "comments_and_likes": "留言與喜歡", + "comments_are_disabled": "留言已停用", "confirm": "確認", "confirm_admin_password": "確認管理者密碼", + "confirm_delete_face": "您確定要從資產中刪除 {name} 的臉部嗎?", "confirm_delete_shared_link": "確定刪除連結嗎?", "confirm_keep_this_delete_others": "所有的其他堆疊項目將被刪除。確定繼續嗎?", "confirm_password": "確認密碼", @@ -526,13 +531,14 @@ "deduplication_criteria_1": "圖像大小(以位元組為單位)", "deduplication_criteria_2": "EXIF 資料數量", "deduplication_info": "重複資料刪除資訊", - "deduplication_info_description": "為了自動預選資產並大量刪除重複項,我們查看:", + "deduplication_info_description": "為了自動預選項目並大量刪除重複項目,我們查看:", "default_locale": "預設區域", "default_locale_description": "依瀏覽器區域設定日期和數字格式", "delete": "刪除", "delete_album": "刪除相簿", "delete_api_key_prompt": "您確定要刪除這個 API Key嗎?", "delete_duplicates_confirmation": "您確定要永久刪除這些重複項嗎?", + "delete_face": "刪除臉部", "delete_key": "刪除密鑰", "delete_library": "刪除圖庫", "delete_link": "刪除鏈結", @@ -544,9 +550,9 @@ "deleted_shared_link": "已刪除共享鏈結", "deletes_missing_assets": "刪除磁碟中遺失的檔案", "description": "描述", - "details": "詳情", + "details": "詳細資訊", "direction": "方向", - "disabled": "禁用", + "disabled": "停用", "disallow_edits": "不允許編輯", "discord": "Discord", "discover": "探索", @@ -561,7 +567,7 @@ "done": "完成", "download": "下載", "download_include_embedded_motion_videos": "嵌入影片", - "download_include_embedded_motion_videos_description": "把嵌入動態照片的影片作爲單獨的檔案包含在內", + "download_include_embedded_motion_videos_description": "把嵌入動態照片的影片作為單獨的檔案包含在內", "download_settings": "下載", "download_settings_description": "管理與檔案下載相關的設定", "downloading": "下載中", @@ -600,17 +606,18 @@ "enabled": "己啟用", "end_date": "結束日期", "error": "錯誤", + "error_delete_face": "從資產中刪除臉部時發生錯誤", "error_loading_image": "載入圖片時出錯", "error_title": "錯誤 - 出問題了", "errors": { "cannot_navigate_next_asset": "無法瀏覽下一個檔案", "cannot_navigate_previous_asset": "無法瀏覽上一個檔案", "cant_apply_changes": "無法套用更改", - "cant_change_activity": "無法{enabled, select, true {禁用} other {啟用}}活動", + "cant_change_activity": "無法{enabled, select, true {停用} other {啟用}}活動", "cant_change_asset_favorite": "無法更改檔案的收藏狀態", "cant_change_metadata_assets_count": "無法更改 {count, plural, other {# 個檔案}}的詳細資料", "cant_get_faces": "無法取得臉孔", - "cant_get_number_of_comments": "無法獲取評論數量", + "cant_get_number_of_comments": "無法取得留言數量", "cant_search_people": "無法搜尋人", "cant_search_places": "無法搜尋地點", "cleared_jobs": "已清除的作業:{job}", @@ -619,7 +626,7 @@ "error_deleting_shared_user": "刪除共享使用者時出錯", "error_downloading": "下載 {filename} 時出錯", "error_hiding_buy_button": "隱藏購置按鈕時出錯", - "error_removing_assets_from_album": "從相簿中移除檔案時出錯了,請到控制臺瞭解詳情", + "error_removing_assets_from_album": "從相簿中移除檔案時出錯了,請到控制臺瞭解詳細資訊", "error_selecting_all_assets": "選擇所有檔案時出錯", "exclusion_pattern_already_exists": "此排除模式已存在。", "failed_job_command": "命令 {command} 執行失敗,作業:{job}", @@ -642,7 +649,7 @@ "repair_unable_to_check_items": "無法檢查 {count, select, other { 個項目}}", "unable_to_add_album_users": "無法將使用者加入相簿", "unable_to_add_assets_to_shared_link": "無法加入項目到共享連結", - "unable_to_add_comment": "無法添加評論", + "unable_to_add_comment": "無法新增留言", "unable_to_add_exclusion_pattern": "無法添加排除模式", "unable_to_add_import_path": "無法添加匯入路徑", "unable_to_add_partners": "無法添加夥伴", @@ -676,7 +683,7 @@ "unable_to_empty_trash": "無法清空垃圾桶", "unable_to_enter_fullscreen": "無法進入全螢幕", "unable_to_exit_fullscreen": "無法退出全螢幕", - "unable_to_get_comments_number": "無法獲取評論數量", + "unable_to_get_comments_number": "無法取得留言數量", "unable_to_get_shared_link": "取得共享連結失敗", "unable_to_hide_person": "無法隱藏人物", "unable_to_link_motion_video": "無法鏈結動態影片", @@ -684,7 +691,7 @@ "unable_to_load_album": "無法載入相簿", "unable_to_load_asset_activity": "無法載入檔案活動", "unable_to_load_items": "無法載入項目", - "unable_to_load_liked_status": "無法載入讚好狀態", + "unable_to_load_liked_status": "無法載入喜歡狀態", "unable_to_log_out_all_devices": "無法登出所有裝置", "unable_to_log_out_device": "無法登出裝置", "unable_to_login_with_oauth": "無法使用 OAuth 登入", @@ -766,8 +773,10 @@ "go_to_folder": "轉至資料夾", "go_to_search": "前往搜尋", "group_albums_by": "分類群組的方式...", + "group_country": "按國家分組", "group_no": "無分組", "group_owner": "按擁有者分組", + "group_places_by": "分類地點的方式...", "group_year": "按年份分組", "has_quota": "配額", "hi_user": "嗨!{name}({email})", @@ -800,6 +809,7 @@ "include_shared_albums": "包含共享相簿", "include_shared_partner_assets": "包括共享夥伴檔案", "individual_share": "個別分享", + "individual_shares": "個別分享", "info": "資訊", "interval": { "day_at_onepm": "每天下午 1 點", @@ -822,6 +832,7 @@ "latest_version": "最新版本", "latitude": "緯度", "leave": "離開", + "lens_model": "鏡頭型號", "let_others_respond": "允許他人回覆", "level": "等級", "library": "圖庫", @@ -880,6 +891,7 @@ "month": "月", "more": "更多", "moved_to_trash": "已丟進垃圾桶", + "mute_memories": "靜音回憶", "my_albums": "我的相簿", "name": "名稱", "name_or_nickname": "名稱或暱稱", @@ -898,7 +910,7 @@ "no_albums_with_name_yet": "看來還沒有這個名字的相簿。", "no_albums_yet": "看來您還沒有任何相簿。", "no_archived_assets_message": "將照片和影片封存,就不會顯示在「照片」中", - "no_assets_message": "按這裏上傳您的第一張照片", + "no_assets_message": "按這裡上傳您的第一張照片", "no_duplicates_found": "沒發現重複項目。", "no_exif_info_available": "沒有可用的 Exif 資訊", "no_explore_results_message": "上傳更多照片以利探索。", @@ -984,6 +996,7 @@ "pick_a_location": "選擇位置", "place": "地點", "places": "地點", + "places_count": "{count, plural, one {{count, number} 個地點} other {{count, number} 個地點}}", "play": "播放", "play_memories": "播放回憶", "play_motion_photo": "播放動態照片", @@ -1019,7 +1032,7 @@ "purchase_license_subtitle": "購置 Immich 來支援軟體開發", "purchase_lifetime_description": "終身購置", "purchase_option_title": "購置選項", - "purchase_panel_info_1": "開發 Immich 可不是件容易的事,花了我們不少功夫。好在有一群全職工程師在背後默默努力,爲的就是把它做到最好。我們的目標很簡單:讓開源軟體和正當的商業模式能成爲開發者的長期飯碗,同時打造出重視隱私的生態系統,讓大家有個不被剝削的雲端服務新選擇。", + "purchase_panel_info_1": "開發 Immich 可不是件容易的事,花了我們不少功夫。好在有一群全職工程師在背後默默努力,為的就是把它做到最好。我們的目標很簡單:讓開源軟體和正當的商業模式能成為開發者的長期飯碗,同時打造出重視隱私的生態系統,讓大家有個不被剝削的雲端服務新選擇。", "purchase_panel_info_2": "我們承諾不設付費牆,所以購置 Immich 並不會讓您獲得額外的功能。我們是依賴使用者們的支援來開發 Immich 的。", "purchase_panel_title": "支援這項專案", "purchase_per_server": "每臺伺服器", @@ -1071,14 +1084,17 @@ "removed_from_archive": "從封存中移除", "removed_from_favorites": "已從收藏中移除", "removed_from_favorites_count": "已移除收藏的 {count, plural, other {# 個項目}}", + "removed_memory": "已移除記憶", + "removed_photo_from_memory": "已從記憶中移除照片", "removed_tagged_assets": "已移除 {count, plural, other {# 個檔案}}的標記", "rename": "改名", "repair": "糾正", - "repair_no_results_message": "未被追蹤及遺失的檔案會顯示在這裏", + "repair_no_results_message": "未被追蹤及遺失的檔案會顯示在這裡", "replace_with_upload": "用上傳的檔案取代", "repository": "儲存庫", "require_password": "需要密碼", "require_user_to_change_password_on_first_login": "要求使用者在首次登入時更改密碼", + "rescan": "重新掃描", "reset": "重設", "reset_password": "重設密碼", "reset_people_visibility": "重設人物可見性", @@ -1099,7 +1115,7 @@ "saved_api_key": "已儲存的 API 密鑰", "saved_profile": "已儲存個人資料", "saved_settings": "已儲存設定", - "say_something": "说些什么", + "say_something": "說說你的想法吧", "scan_all_libraries": "掃描所有圖庫", "scan_library": "掃描", "scan_settings": "掃描設定", @@ -1107,18 +1123,22 @@ "search": "搜尋", "search_albums": "搜尋相簿", "search_by_context": "以情境搜尋", + "search_by_description": "以描述搜尋", + "search_by_description_example": "在沙壩的健行之日", "search_by_filename": "以檔名或副檔名搜尋", "search_by_filename_example": "如 IMG_1234.JPG 或 PNG", "search_camera_make": "搜尋相機製造商…", "search_camera_model": "搜尋相機型號…", "search_city": "搜尋城市…", "search_country": "搜尋國家…", + "search_for": "搜尋", "search_for_existing_person": "搜尋現有的人物", "search_no_people": "沒有人找到", - "search_no_people_named": "沒有名爲「{name}」的人物", + "search_no_people_named": "沒有名為「{name}」的人物", "search_options": "搜尋選項", "search_people": "搜尋人物", "search_places": "搜尋地點", + "search_rating": "按評分搜尋...", "search_settings": "搜尋設定", "search_state": "搜尋地區…", "search_tags": "搜尋標籤...", @@ -1144,12 +1164,12 @@ "selected_count": "{count, plural, other {選了 # 項}}", "send_message": "傳訊息", "send_welcome_email": "傳送歡迎電子郵件", - "server_offline": "伺服器離線", - "server_online": "伺服器在線", + "server_offline": "伺服器已離線", + "server_online": "伺服器已上線", "server_stats": "伺服器統計", "server_version": "目前版本", "set": "設定", - "set_as_album_cover": "設爲相簿封面", + "set_as_album_cover": "設為相簿封面", "set_as_featured_photo": "設為特色照片", "set_as_profile_picture": "設為個人資料圖片", "set_date_of_birth": "設定出生日期", @@ -1165,6 +1185,7 @@ "shared_from_partner": "來自 {partner} 的照片", "shared_link_options": "共享連結選項", "shared_links": "共享連結", + "shared_links_description": "以連結分享照片和影片", "shared_photos_and_videos_count": "{assetCount, plural, other {已分享 # 張照片及影片。}}", "shared_with_partner": "與 {partner} 共享", "sharing": "共享", @@ -1187,6 +1208,7 @@ "show_person_options": "顯示人物選項", "show_progress_bar": "顯示進度條", "show_search_options": "顯示搜尋選項", + "show_shared_links": "顯示共享連結", "show_slideshow_transition": "顯示幻燈片轉場", "show_supporter_badge": "擁護者徽章", "show_supporter_badge_description": "顯示擁護者徽章", @@ -1212,7 +1234,7 @@ "source": "來源", "stack": "堆叠", "stack_duplicates": "堆疊重複項目", - "stack_select_one_photo": "爲堆疊選一張主要照片", + "stack_select_one_photo": "為堆疊選一張主要照片", "stack_selected_photos": "堆疊所選的照片", "stacked_assets_count": "已堆疊 {count, plural, one {# 個檔案} other {# 個檔案}}", "stacktrace": "堆疊追蹤", @@ -1240,6 +1262,7 @@ "tag_created": "已建立標記:{tag}", "tag_feature_description": "以邏輯標記要旨分組瀏覽照片和影片", "tag_not_found_question": "找不到標記?建立新標記吧。", + "tag_people": "標記人物", "tag_updated": "已更新標記:{tag}", "tagged_assets": "已標記 {count, plural, other {# 個檔案}}", "tags": "標籤", @@ -1274,11 +1297,13 @@ "unfavorite": "取消收藏", "unhide_person": "取消隱藏人物", "unknown": "未知", + "unknown_country": "未知國家", "unknown_year": "不知年份", "unlimited": "不限制", "unlink_motion_video": "取消鏈結動態影片", "unlink_oauth": "取消連接 OAuth", "unlinked_oauth_account": "已解除連接 OAuth 帳號", + "unmute_memories": "取消靜音回憶", "unnamed_album": "未命名相簿", "unnamed_album_delete_confirmation": "確定要刪除這本相簿嗎?", "unnamed_share": "未命名分享", @@ -1308,8 +1333,8 @@ "user_liked": "{user} 喜歡了 {type, select, photo {這張照片} video {這段影片} asset {這個檔案} other {它}}", "user_purchase_settings": "購置", "user_purchase_settings_description": "管理你的購買", - "user_role_set": "設 {user} 爲{role}", - "user_usage_detail": "使用者用量詳情", + "user_role_set": "設 {user} 為{role}", + "user_usage_detail": "使用者用量詳細資訊", "user_usage_stats": "帳號使用量統計", "user_usage_stats_description": "查看帳號使用量", "username": "使用者名稱", @@ -1319,7 +1344,7 @@ "variables": "變數", "version": "版本", "version_announcement_closing": "敬祝順心,Alex", - "version_announcement_message": "嗨~新版本的 Immich 推出了。爲防止配置出錯,請花點時間閱讀發行說明,並確保設定是最新的,特別是使用 WatchTower 等自動更新工具時。", + "version_announcement_message": "嗨~新版本的 Immich 推出了。為防止配置出錯,請花點時間閱讀發行說明,並確保設定是最新的,特別是使用 WatchTower 等自動更新工具時。", "version_history": "版本紀錄", "version_history_item": "{date} 安裝了 {version}", "video": "影片", @@ -1332,15 +1357,16 @@ "view_all": "瀏覽全部", "view_all_users": "查看所有使用者", "view_in_timeline": "在時間軸中查看", + "view_link": "查看連結", "view_links": "檢視鏈結", - "view_name": "查看", + "view_name": "檢視分類", "view_next_asset": "查看下一項", "view_previous_asset": "查看上一項", "view_stack": "查看堆疊", "visibility_changed": "已更改 {count, plural, other {# 位人物}}的可見性", "waiting": "待處理", "warning": "警告", - "week": "周", + "week": "週", "welcome": "歡迎", "welcome_to_immich": "歡迎使用 Immich", "year": "年", @@ -1348,4 +1374,4 @@ "yes": "是", "you_dont_have_any_shared_links": "您沒有任何共享連結", "zoom_image": "縮放圖片" -} +} \ No newline at end of file diff --git a/i18n/zh_SIMPLIFIED.json b/i18n/zh_SIMPLIFIED.json index 12c72a8172..f6ef01e353 100644 --- a/i18n/zh_SIMPLIFIED.json +++ b/i18n/zh_SIMPLIFIED.json @@ -20,7 +20,7 @@ "add_partner": "添加同伴", "add_path": "添加路径", "add_photos": "添加照片", - "add_to": "添加到...", + "add_to": "添加到…", "add_to_album": "添加到相册", "add_to_shared_album": "添加到共享相册", "add_url": "添加URL", @@ -41,6 +41,7 @@ "backup_settings": "备份设置", "backup_settings_description": "管理数据库备份设置", "check_all": "检查全部", + "cleanup": "清理", "cleared_jobs": "已清理任务:{job}", "config_set_by_file": "当前配置已通过配置文件设置", "confirm_delete_library": "确定要删除图库“{library}”吗?", @@ -96,7 +97,7 @@ "library_scanning_enable_description": "启用定期扫描图库", "library_settings": "外部图库", "library_settings_description": "管理外部图库设置", - "library_tasks_description": "执行图库任务", + "library_tasks_description": "扫描外部库,查找新增或修改的项目", "library_watching_enable_description": "监控外部图库文件变化", "library_watching_settings": "监控图库(实验性)", "library_watching_settings_description": "自动监控文件变化", @@ -131,7 +132,7 @@ "machine_learning_smart_search_description": "使用 CLIP 以文搜图、智能搜图", "machine_learning_smart_search_enabled": "启用智能搜索", "machine_learning_smart_search_enabled_description": "如果禁用,则不会对图像编码以用于智能搜索。", - "machine_learning_url_description": "机器学习服务器的 URL。如果提供多个 URL,则将按依次尝试连接每个服务器,直到有一个服务器成功响应为止。", + "machine_learning_url_description": "机器学习服务器的 URL。如果提供多个 URL,则将按依次尝试连接每个服务器,直到有一个服务器成功响应为止。不响应的服务器将被暂时忽略,直到它们重新联机。", "manage_concurrency": "管理任务并发", "manage_log_settings": "管理日志设置", "map_dark_style": "深色模式", @@ -147,6 +148,8 @@ "map_settings": "地图", "map_settings_description": "管理地图设置", "map_style_description": "地图主题 style.json 的 URL", + "memory_cleanup_job": "清空回忆", + "memory_generate_job": "生成回忆", "metadata_extraction_job": "提取元数据", "metadata_extraction_job_description": "从每个项目中提取元数据信息,如 GPS、人脸和分辨率", "metadata_faces_import_setting": "启用人脸导入", @@ -219,7 +222,7 @@ "reset_settings_to_default": "恢复默认设置", "reset_settings_to_recent_saved": "恢复到最近保存的设置", "scanning_library": "扫描图库", - "search_jobs": "搜索任务...", + "search_jobs": "搜索任务…", "send_welcome_email": "发送欢迎邮件", "server_external_domain_settings": "外部域名", "server_external_domain_settings_description": "共享链接域名,包括 http(s)://", @@ -240,7 +243,7 @@ "storage_template_hash_verification_enabled_description": "启用哈希校验,如果您不知道此项的作用请不要禁用此功能", "storage_template_migration": "存储模板转换", "storage_template_migration_description": "应用当前的{template}到之前上传的项目", - "storage_template_migration_info": "模板修改将只作用于新的项目。如也需应用此模板到之前上传的项目,请运行{job}。", + "storage_template_migration_info": "存储模板会将所有扩展名转换为小写。模板修改只会作用于新的项目,如需应用此模板到之前上传的项目,请运行{job}。", "storage_template_migration_job": "存储模板转换任务", "storage_template_more_details": "关于本功能的更多细节,请参见存储模板及其实现方式", "storage_template_onboarding_description": "启用后,本功能将根据用户定义的模板自动整理文件。出于稳定性考虑,本功能默认是禁用的。更多详细信息请参见 文档。", @@ -299,7 +302,7 @@ "transcoding_max_b_frames": "最大B帧数", "transcoding_max_b_frames_description": "较高的值可以提高压缩效率,但会减慢编码速度。可能与旧设备上的硬件加速不兼容。0表示将禁用B帧,-1表示将自动设置此参数。", "transcoding_max_bitrate": "最高码率", - "transcoding_max_bitrate_description": "设置最大比特率可在对输出质量影响较小的情况下,使文件体积更为可控。720p 下,VP9 或 HEVC 普遍将其设为 2600K,H.264 则为 4500K。如果此项设置为 0,则不限制最大比特率。", + "transcoding_max_bitrate_description": "设置最大比特率可在对输出质量影响较小的情况下,使文件体积更为可控。720p 下,VP9 或 HEVC 普遍将其设为 2600 kbit/s,H.264 则为 4500 kbit/s。如果此项设置为 0,则不限制最大比特率。", "transcoding_max_keyframe_interval": "最大关键帧间隔", "transcoding_max_keyframe_interval_description": "设置关键帧之间的最大帧距离。较低的值会降低压缩效率,但可以提高搜索速度,并且可能在快速运动的场景中提高画质。0 表示将自动设置此参数。", "transcoding_optimal_description": "视频超过目标分辨率或格式不支持", @@ -391,6 +394,7 @@ "allow_edits": "允许编辑", "allow_public_user_to_download": "允许所有用户下载", "allow_public_user_to_upload": "允许所有用户上传", + "alt_text_qr_code": "二维码图片", "anti_clockwise": "逆时针", "api_key": "API 密钥", "api_key_description": "该应用密钥只会显示一次。请确保在关闭窗口前复制下来。", @@ -406,17 +410,17 @@ "are_these_the_same_person": "他们是同一位人吗?", "are_you_sure_to_do_this": "确定要这样做吗?", "asset_added_to_album": "已添加至相册", - "asset_adding_to_album": "正在添加至相册...", + "asset_adding_to_album": "正在添加至相册…", "asset_description_updated": "项目描述已更新", "asset_filename_is_offline": "项目“{filename}”已离线", "asset_has_unassigned_faces": "项目中有未分配的人脸", - "asset_hashing": "哈希校验中...", + "asset_hashing": "哈希校验中…", "asset_offline": "项目脱机", "asset_offline_description": "磁盘上已找不到该外部项目。请联系您的 Immich 管理员寻求帮助。", "asset_skipped": "已跳过", "asset_skipped_in_trash": "已回收", "asset_uploaded": "已上传", - "asset_uploading": "上传中...", + "asset_uploading": "上传中…", "assets": "项目", "assets_added_count": "已添加{count, plural, one {#个项目} other {#个项目}}", "assets_added_to_album_count": "已添加{count, plural, one {#个项目} other {#个项目}}到相册", @@ -481,6 +485,7 @@ "comments_are_disabled": "评论已禁用", "confirm": "确认", "confirm_admin_password": "确认管理员密码", + "confirm_delete_face": "您确定要从资产中删除 {name} 的脸吗?", "confirm_delete_shared_link": "你确定要删除此共享链接吗?", "confirm_keep_this_delete_others": "除此项目外,堆叠中的所有其它项目都将被删除。你确定要继续吗?", "confirm_password": "确认密码", @@ -533,6 +538,7 @@ "delete_album": "删除相册", "delete_api_key_prompt": "确定删除此API key吗?", "delete_duplicates_confirmation": "你要永久删除这些重复项吗?", + "delete_face": "删除人脸", "delete_key": "删除密钥", "delete_library": "删除图库", "delete_link": "删除链接", @@ -600,6 +606,7 @@ "enabled": "已启用", "end_date": "结束日期", "error": "错误", + "error_delete_face": "删除人脸失败", "error_loading_image": "加载图片时出错", "error_title": "错误 - 好像出了问题", "errors": { @@ -766,8 +773,10 @@ "go_to_folder": "进入文件夹", "go_to_search": "前往搜索", "group_albums_by": "相册分组依据...", + "group_country": "按国家分组", "group_no": "未分组", "group_owner": "按所有者分组", + "group_places_by": "地点分组依据...", "group_year": "按年分组", "has_quota": "配额大小", "hi_user": "你好,{name}({email})", @@ -800,6 +809,7 @@ "include_shared_albums": "包括共享相册", "include_shared_partner_assets": "包括同伴共享项目", "individual_share": "个人分享", + "individual_shares": "个人分享", "info": "信息", "interval": { "day_at_onepm": "每天下午 1 点", @@ -822,6 +832,7 @@ "latest_version": "最新版本", "latitude": "纬度", "leave": "离开", + "lens_model": "镜头型号", "let_others_respond": "允许他人回应", "level": "等级", "library": "图库", @@ -880,6 +891,7 @@ "month": "月", "more": "更多", "moved_to_trash": "已放入回收站", + "mute_memories": "静音回忆", "my_albums": "我的相册", "name": "姓名", "name_or_nickname": "名称或昵称", @@ -984,6 +996,7 @@ "pick_a_location": "选择位置", "place": "地点", "places": "地点", + "places_count": "{count, plural, one {{count, number} 个地点} other {{count, number} 个地点}}", "play": "播放", "play_memories": "播放回忆", "play_motion_photo": "播放动态图片", @@ -1071,6 +1084,8 @@ "removed_from_archive": "从归档中移除", "removed_from_favorites": "从收藏中移除", "removed_from_favorites_count": "从收藏中移除{count, plural, other {#项}}", + "removed_memory": "已删除的回忆", + "removed_photo_from_memory": "从回忆中删除的照片", "removed_tagged_assets": "从 {count, plural, one {# 个项目} other {# 个项目}}中删除标签", "rename": "重命名", "repair": "修复", @@ -1079,6 +1094,7 @@ "repository": "库", "require_password": "需要密码", "require_user_to_change_password_on_first_login": "要求用户在首次登录时更改密码", + "rescan": "重新扫描", "reset": "重置", "reset_password": "重置密码", "reset_people_visibility": "重置人物可见性", @@ -1106,19 +1122,23 @@ "scanning_for_album": "扫描相册中...", "search": "搜索", "search_albums": "搜索相册", - "search_by_context": "搜索内容", - "search_by_filename": "通过文件名搜索", + "search_by_context": "按照片情景搜索", + "search_by_description": "按描述搜索", + "search_by_description_example": "在沙巴徒步的日子", + "search_by_filename": "按文件名搜索", "search_by_filename_example": "如 IMG_1234.JPG 或 PNG", "search_camera_make": "搜索相机品牌...", "search_camera_model": "搜索相机型号...", "search_city": "搜索城市...", "search_country": "搜索国家...", + "search_for": "搜索", "search_for_existing_person": "搜索已有人物", "search_no_people": "找不到人物", "search_no_people_named": "人物“{name}”不存在", "search_options": "搜索选项", "search_people": "搜索人物", "search_places": "搜索地点", + "search_rating": "按星级搜索...", "search_settings": "搜索设置", "search_state": "搜索省份...", "search_tags": "搜索标签…", @@ -1165,6 +1185,7 @@ "shared_from_partner": "来自“{partner}”的照片", "shared_link_options": "共享链接选项", "shared_links": "共享链接", + "shared_links_description": "通过链接分享照片和视频", "shared_photos_and_videos_count": "{assetCount, plural, other {#项已共享照片&视频。}}", "shared_with_partner": "与“{partner}”共享", "sharing": "共享", @@ -1187,6 +1208,7 @@ "show_person_options": "显示人物选项", "show_progress_bar": "显示进度条", "show_search_options": "显示搜索选项", + "show_shared_links": "显示共享链接", "show_slideshow_transition": "显示幻灯片过渡效果", "show_supporter_badge": "支持者徽章", "show_supporter_badge_description": "展示支持者徽章", @@ -1240,6 +1262,7 @@ "tag_created": "已创建标签:{tag}", "tag_feature_description": "按逻辑标签分组并浏览照片和视频", "tag_not_found_question": "找不到标签吗?创建新标签", + "tag_people": "命名人物", "tag_updated": "已更新标签:{tag}", "tagged_assets": "{count, plural, one {# 个项目} other {# 个项目}}被加上标签", "tags": "标签", @@ -1274,11 +1297,13 @@ "unfavorite": "取消收藏", "unhide_person": "显示人物", "unknown": "未知", + "unknown_country": "未知的国家", "unknown_year": "未知年份", "unlimited": "无限制", "unlink_motion_video": "取消链接动态视频", "unlink_oauth": "解绑 OAuth", "unlinked_oauth_account": "解绑 OAuth 账户", + "unmute_memories": "取消静音回忆", "unnamed_album": "未命名相册", "unnamed_album_delete_confirmation": "您确定要删除该相册吗?", "unnamed_share": "未命名共享", @@ -1332,6 +1357,7 @@ "view_all": "查看全部", "view_all_users": "查看全部用户", "view_in_timeline": "在时间轴中查看", + "view_link": "查看链接", "view_links": "查看链接", "view_name": "查看", "view_next_asset": "查看下一项", @@ -1348,4 +1374,4 @@ "yes": "是", "you_dont_have_any_shared_links": "您没有任何共享链接", "zoom_image": "缩放图像" -} +} \ No newline at end of file diff --git a/install.sh b/install.sh index e9c65b3283..ccefe4e894 100755 --- a/install.sh +++ b/install.sh @@ -51,9 +51,13 @@ start_docker_compose() { show_friendly_message() { local ip_address ip_address=$(hostname -I | awk '{print $1}') + # If length of ip_address is 0, then we are on a Mac + if [ ${#ip_address} -eq 0 ]; then + ip_address=$(ipconfig getifaddr en0) + fi cat <> /etc/security/limits.conf && \ echo "fs.suid_dumpable 0" >> /etc/sysctl.conf && \ echo 'ulimit -S -c 0 > /dev/null 2>&1' >> /etc/profile -COPY --from=builder /opt/venv /opt/venv +COPY --from=builder /usr/src/app/.venv /usr/src/app/.venv COPY ann/ann.py /usr/src/ann/ann.py COPY rknn/rknnpool.py /usr/src/rknn/rknnpool.py COPY start.sh log_conf.json gunicorn_conf.py ./ COPY app . + +ARG BUILD_ID +ARG BUILD_IMAGE +ARG BUILD_SOURCE_REF +ARG BUILD_SOURCE_COMMIT + +ENV IMMICH_BUILD=${BUILD_ID} +ENV IMMICH_BUILD_URL=https://github.com/immich-app/immich/actions/runs/${BUILD_ID} +ENV IMMICH_BUILD_IMAGE=${BUILD_IMAGE} +ENV IMMICH_BUILD_IMAGE_URL=https://github.com/immich-app/immich/pkgs/container/immich-machine-learning +ENV IMMICH_REPOSITORY=immich-app/immich +ENV IMMICH_REPOSITORY_URL=https://github.com/immich-app/immich +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} + ENTRYPOINT ["tini", "--"] CMD ["./start.sh"] diff --git a/machine-learning/README.md b/machine-learning/README.md index d7d099a87e..4146a6f9de 100644 --- a/machine-learning/README.md +++ b/machine-learning/README.md @@ -5,13 +5,12 @@ # Setup -This project uses [Poetry](https://python-poetry.org/docs/#installation), so be sure to install it first. -Running `poetry install --no-root --with dev --with cpu` will install everything you need in an isolated virtual environment. -CUDA and OpenVINO are supported as acceleration APIs. To use them, you can replace `--with cpu` with either of `--with cuda` or `--with openvino`. In the case of CUDA, a [compute capability](https://developer.nvidia.com/cuda-gpus) of 5.2 or higher is required. - -To add or remove dependencies, you can use the commands `poetry add $PACKAGE_NAME` and `poetry remove $PACKAGE_NAME`, respectively. -Be sure to commit the `poetry.lock` and `pyproject.toml` files with `poetry lock --no-update` to reflect any changes in dependencies. +This project uses [uv](https://docs.astral.sh/uv/getting-started/installation/), so be sure to install it first. +Running `uv sync --extra cpu` will install everything you need in an isolated virtual environment. +CUDA and OpenVINO are supported as acceleration APIs. To use them, you can replace `--group cpu` with either of `--group cuda` or `--group openvino`. In the case of CUDA, a [compute capability](https://developer.nvidia.com/cuda-gpus) of 5.2 or higher is required. +To add or remove dependencies, you can use the commands `uv add $PACKAGE_NAME` and `uv remove $PACKAGE_NAME`, respectively. +Be sure to commit the `uv.lock` and `pyproject.toml` files with `uv lock` to reflect any changes in dependencies. # Load Testing @@ -19,22 +18,25 @@ To measure inference throughput and latency, you can use [Locust](https://locust Locust works by querying the model endpoints and aggregating their statistics, meaning the app must be deployed. You can change the models or adjust options like score thresholds through the Locust UI. -To get started, you can simply run `locust --web-host 127.0.0.1` and open `localhost:8089` in a browser to access the UI. See the [Locust documentation](https://docs.locust.io/en/stable/index.html) for more info on running Locust. +To get started, you can simply run `locust --web-host 127.0.0.1` and open `localhost:8089` in a browser to access the UI. See the [Locust documentation](https://docs.locust.io/en/stable/index.html) for more info on running Locust. Note that in Locust's jargon, concurrency is measured in `users`, and each user runs one task at a time. To achieve a particular per-endpoint concurrency, multiply that number by the number of endpoints to be queried. For example, if there are 3 endpoints and you want each of them to receive 8 requests at a time, you should set the number of users to 24. # Facial Recognition ## Acknowledgements + This project utilizes facial recognition models from the [InsightFace](https://github.com/deepinsight/insightface/tree/master/model_zoo) project. We appreciate the work put into developing these models, which have been beneficial to the machine learning part of this project. ### Used Models -* antelopev2 -* buffalo_l -* buffalo_m -* buffalo_s + +- antelopev2 +- buffalo_l +- buffalo_m +- buffalo_s ## License and Use Restrictions + We have received permission to use the InsightFace facial recognition models in our project, as granted via email by Jia Guo (guojia@insightface.ai) on 18th March 2023. However, it's important to note that this permission does not extend to the redistribution or commercial use of their models by third parties. Users and developers interested in using these models should review the licensing terms provided in the InsightFace GitHub repository. -For more information on the capabilities of the InsightFace models and to ensure compliance with their license, please refer to their [official repository](https://github.com/deepinsight/insightface). Adhering to the specified licensing terms is crucial for the respectful and lawful use of their work. \ No newline at end of file +For more information on the capabilities of the InsightFace models and to ensure compliance with their license, please refer to their [official repository](https://github.com/deepinsight/insightface). Adhering to the specified licensing terms is crucial for the respectful and lawful use of their work. diff --git a/machine-learning/app/models/facial_recognition/recognition.py b/machine-learning/app/models/facial_recognition/recognition.py index 044f19b06f..5e8a6f69ec 100644 --- a/machine-learning/app/models/facial_recognition/recognition.py +++ b/machine-learning/app/models/facial_recognition/recognition.py @@ -20,9 +20,8 @@ class FaceRecognizer(InferenceModel): depends = [(ModelType.DETECTION, ModelTask.FACIAL_RECOGNITION)] identity = (ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION) - def __init__(self, model_name: str, min_score: float = 0.7, **model_kwargs: Any) -> None: + def __init__(self, model_name: str, **model_kwargs: Any) -> None: super().__init__(model_name, **model_kwargs) - self.min_score = model_kwargs.pop("minScore", min_score) max_batch_size = settings.max_batch_size.facial_recognition if settings.max_batch_size else None self.batch_size = max_batch_size if max_batch_size else self._batch_size_default diff --git a/machine-learning/app/test_main.py b/machine-learning/app/test_main.py index 74bd8aa324..9dc589bd06 100644 --- a/machine-learning/app/test_main.py +++ b/machine-learning/app/test_main.py @@ -344,7 +344,7 @@ class TestAnnSession: session.run(None, input_feed) ann_session.return_value.execute.assert_called_once_with(123, [input1, input2]) - np_spy.call_count == 2 + assert np_spy.call_count == 2 np_spy.assert_has_calls([mock.call(input1), mock.call(input2)]) @@ -507,11 +507,14 @@ class TestCLIP: class TestFaceRecognition: - def test_set_min_score(self, mocker: MockerFixture) -> None: - mocker.patch.object(FaceRecognizer, "load") - face_recognizer = FaceRecognizer("buffalo_s", cache_dir="test_cache", min_score=0.5) + 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" - assert face_recognizer.min_score == 0.5 + 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: mocker.patch.object(FaceDetector, "load") diff --git a/machine-learning/locustfile.py b/machine-learning/locustfile.py index 81087bee8c..9a07a99688 100644 --- a/machine-learning/locustfile.py +++ b/machine-learning/locustfile.py @@ -14,12 +14,6 @@ byte_image = BytesIO() def _(parser: ArgumentParser) -> None: parser.add_argument("--clip-model", type=str, default="ViT-B-32::openai") parser.add_argument("--face-model", type=str, default="buffalo_l") - parser.add_argument( - "--tag-min-score", - type=int, - default=0.0, - help="Returns all tags at or above this score. The default returns all tags.", - ) parser.add_argument( "--face-min-score", type=int, @@ -74,10 +68,10 @@ class RecognitionFormDataLoadTest(InferenceLoadTest): "facial-recognition": { "recognition": { "modelName": self.environment.parsed_options.face_model, - "options": {"minScore": self.environment.parsed_options.face_min_score}, }, "detection": { "modelName": self.environment.parsed_options.face_model, + "options": {"minScore": self.environment.parsed_options.face_min_score}, }, } } diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock deleted file mode 100644 index 732411534f..0000000000 --- a/machine-learning/poetry.lock +++ /dev/null @@ -1,3828 +0,0 @@ -# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. - -[[package]] -name = "aiocache" -version = "0.12.3" -description = "multi backend asyncio cache" -optional = false -python-versions = "*" -files = [ - {file = "aiocache-0.12.3-py2.py3-none-any.whl", hash = "sha256:889086fc24710f431937b87ad3720a289f7fc31c4fd8b68e9f918b9bacd8270d"}, - {file = "aiocache-0.12.3.tar.gz", hash = "sha256:f528b27bf4d436b497a1d0d1a8f59a542c153ab1e37c3621713cb376d44c4713"}, -] - -[package.extras] -memcached = ["aiomcache (>=0.5.2)"] -msgpack = ["msgpack (>=0.5.5)"] -redis = ["redis (>=4.2.0)"] - -[[package]] -name = "albumentations" -version = "1.3.1" -description = "Fast image augmentation library and easy to use wrapper around other libraries" -optional = false -python-versions = ">=3.7" -files = [ - {file = "albumentations-1.3.1-py3-none-any.whl", hash = "sha256:6b641d13733181d9ecdc29550e6ad580d1bfa9d25e2213a66940062f25e291bd"}, - {file = "albumentations-1.3.1.tar.gz", hash = "sha256:a6a38388fe546c568071e8c82f414498e86c9ed03c08b58e7a88b31cf7a244c6"}, -] - -[package.dependencies] -numpy = ">=1.11.1" -opencv-python-headless = ">=4.1.1" -PyYAML = "*" -qudida = ">=0.0.4" -scikit-image = ">=0.16.1" -scipy = ">=1.1.0" - -[package.extras] -develop = ["imgaug (>=0.4.0)", "pytest"] -imgaug = ["imgaug (>=0.4.0)"] -tests = ["pytest"] - -[[package]] -name = "annotated-types" -version = "0.7.0" -description = "Reusable constraint types to use with typing.Annotated" -optional = false -python-versions = ">=3.8" -files = [ - {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, - {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, -] - -[[package]] -name = "anyio" -version = "4.2.0" -description = "High level compatibility layer for multiple asynchronous event loop implementations" -optional = false -python-versions = ">=3.8" -files = [ - {file = "anyio-4.2.0-py3-none-any.whl", hash = "sha256:745843b39e829e108e518c489b31dc757de7d2131d53fac32bd8df268227bfee"}, - {file = "anyio-4.2.0.tar.gz", hash = "sha256:e1875bb4b4e2de1669f4bc7869b6d3f54231cdced71605e6e64c9be77e3be50f"}, -] - -[package.dependencies] -exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} -idna = ">=2.8" -sniffio = ">=1.1" -typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} - -[package.extras] -doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] -trio = ["trio (>=0.23)"] - -[[package]] -name = "black" -version = "24.10.0" -description = "The uncompromising code formatter." -optional = false -python-versions = ">=3.9" -files = [ - {file = "black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812"}, - {file = "black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea"}, - {file = "black-24.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f"}, - {file = "black-24.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e"}, - {file = "black-24.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad"}, - {file = "black-24.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50"}, - {file = "black-24.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392"}, - {file = "black-24.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175"}, - {file = "black-24.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3"}, - {file = "black-24.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65"}, - {file = "black-24.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f"}, - {file = "black-24.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8"}, - {file = "black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981"}, - {file = "black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b"}, - {file = "black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2"}, - {file = "black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b"}, - {file = "black-24.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd"}, - {file = "black-24.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f"}, - {file = "black-24.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800"}, - {file = "black-24.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7"}, - {file = "black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d"}, - {file = "black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875"}, -] - -[package.dependencies] -click = ">=8.0.0" -mypy-extensions = ">=0.4.3" -packaging = ">=22.0" -pathspec = ">=0.9.0" -platformdirs = ">=2" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} - -[package.extras] -colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.10)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] - -[[package]] -name = "blinker" -version = "1.7.0" -description = "Fast, simple object-to-object and broadcast signaling" -optional = false -python-versions = ">=3.8" -files = [ - {file = "blinker-1.7.0-py3-none-any.whl", hash = "sha256:c3f865d4d54db7abc53758a01601cf343fe55b84c1de4e3fa910e420b438d5b9"}, - {file = "blinker-1.7.0.tar.gz", hash = "sha256:e6820ff6fa4e4d1d8e2747c2283749c3f547e4fee112b98555cdcdae32996182"}, -] - -[[package]] -name = "brotli" -version = "1.1.0" -description = "Python bindings for the Brotli compression library" -optional = false -python-versions = "*" -files = [ - {file = "Brotli-1.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e1140c64812cb9b06c922e77f1c26a75ec5e3f0fb2bf92cc8c58720dec276752"}, - {file = "Brotli-1.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c8fd5270e906eef71d4a8d19b7c6a43760c6abcfcc10c9101d14eb2357418de9"}, - {file = "Brotli-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ae56aca0402a0f9a3431cddda62ad71666ca9d4dc3a10a142b9dce2e3c0cda3"}, - {file = "Brotli-1.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:43ce1b9935bfa1ede40028054d7f48b5469cd02733a365eec8a329ffd342915d"}, - {file = "Brotli-1.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7c4855522edb2e6ae7fdb58e07c3ba9111e7621a8956f481c68d5d979c93032e"}, - {file = "Brotli-1.1.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:38025d9f30cf4634f8309c6874ef871b841eb3c347e90b0851f63d1ded5212da"}, - {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e6a904cb26bfefc2f0a6f240bdf5233be78cd2488900a2f846f3c3ac8489ab80"}, - {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a37b8f0391212d29b3a91a799c8e4a2855e0576911cdfb2515487e30e322253d"}, - {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e84799f09591700a4154154cab9787452925578841a94321d5ee8fb9a9a328f0"}, - {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f66b5337fa213f1da0d9000bc8dc0cb5b896b726eefd9c6046f699b169c41b9e"}, - {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5dab0844f2cf82be357a0eb11a9087f70c5430b2c241493fc122bb6f2bb0917c"}, - {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e4fe605b917c70283db7dfe5ada75e04561479075761a0b3866c081d035b01c1"}, - {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1e9a65b5736232e7a7f91ff3d02277f11d339bf34099a56cdab6a8b3410a02b2"}, - {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:58d4b711689366d4a03ac7957ab8c28890415e267f9b6589969e74b6e42225ec"}, - {file = "Brotli-1.1.0-cp310-cp310-win32.whl", hash = "sha256:be36e3d172dc816333f33520154d708a2657ea63762ec16b62ece02ab5e4daf2"}, - {file = "Brotli-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c6244521dda65ea562d5a69b9a26120769b7a9fb3db2fe9545935ed6735b128"}, - {file = "Brotli-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc"}, - {file = "Brotli-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c8146669223164fc87a7e3de9f81e9423c67a79d6b3447994dfb9c95da16e2d6"}, - {file = "Brotli-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30924eb4c57903d5a7526b08ef4a584acc22ab1ffa085faceb521521d2de32dd"}, - {file = "Brotli-1.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ceb64bbc6eac5a140ca649003756940f8d6a7c444a68af170b3187623b43bebf"}, - {file = "Brotli-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a469274ad18dc0e4d316eefa616d1d0c2ff9da369af19fa6f3daa4f09671fd61"}, - {file = "Brotli-1.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:524f35912131cc2cabb00edfd8d573b07f2d9f21fa824bd3fb19725a9cf06327"}, - {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5b3cc074004d968722f51e550b41a27be656ec48f8afaeeb45ebf65b561481dd"}, - {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9"}, - {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265"}, - {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8"}, - {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c247dd99d39e0338a604f8c2b3bc7061d5c2e9e2ac7ba9cc1be5a69cb6cd832f"}, - {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1b2c248cd517c222d89e74669a4adfa5577e06ab68771a529060cf5a156e9757"}, - {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2a24c50840d89ded6c9a8fdc7b6ed3692ed4e86f1c4a4a938e1e92def92933e0"}, - {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f31859074d57b4639318523d6ffdca586ace54271a73ad23ad021acd807eb14b"}, - {file = "Brotli-1.1.0-cp311-cp311-win32.whl", hash = "sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50"}, - {file = "Brotli-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1"}, - {file = "Brotli-1.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:32d95b80260d79926f5fab3c41701dbb818fde1c9da590e77e571eefd14abe28"}, - {file = "Brotli-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b760c65308ff1e462f65d69c12e4ae085cff3b332d894637f6273a12a482d09f"}, - {file = "Brotli-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409"}, - {file = "Brotli-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2"}, - {file = "Brotli-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451"}, - {file = "Brotli-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f4bf76817c14aa98cc6697ac02f3972cb8c3da93e9ef16b9c66573a68014f91"}, - {file = "Brotli-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0c5516f0aed654134a2fc936325cc2e642f8a0e096d075209672eb321cff408"}, - {file = "Brotli-1.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c3020404e0b5eefd7c9485ccf8393cfb75ec38ce75586e046573c9dc29967a0"}, - {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4ed11165dd45ce798d99a136808a794a748d5dc38511303239d4e2363c0695dc"}, - {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180"}, - {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248"}, - {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966"}, - {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87a3044c3a35055527ac75e419dfa9f4f3667a1e887ee80360589eb8c90aabb9"}, - {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c5529b34c1c9d937168297f2c1fde7ebe9ebdd5e121297ff9c043bdb2ae3d6fb"}, - {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ca63e1890ede90b2e4454f9a65135a4d387a4585ff8282bb72964fab893f2111"}, - {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e79e6520141d792237c70bcd7a3b122d00f2613769ae0cb61c52e89fd3443839"}, - {file = "Brotli-1.1.0-cp312-cp312-win32.whl", hash = "sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0"}, - {file = "Brotli-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951"}, - {file = "Brotli-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5"}, - {file = "Brotli-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8"}, - {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f"}, - {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91d7cc2a76b5567591d12c01f019dd7afce6ba8cba6571187e21e2fc418ae648"}, - {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a93dde851926f4f2678e704fadeb39e16c35d8baebd5252c9fd94ce8ce68c4a0"}, - {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0db75f47be8b8abc8d9e31bc7aad0547ca26f24a54e6fd10231d623f183d089"}, - {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6967ced6730aed543b8673008b5a391c3b1076d834ca438bbd70635c73775368"}, - {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7eedaa5d036d9336c95915035fb57422054014ebdeb6f3b42eac809928e40d0c"}, - {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d487f5432bf35b60ed625d7e1b448e2dc855422e87469e3f450aa5552b0eb284"}, - {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:832436e59afb93e1836081a20f324cb185836c617659b07b129141a8426973c7"}, - {file = "Brotli-1.1.0-cp313-cp313-win32.whl", hash = "sha256:43395e90523f9c23a3d5bdf004733246fba087f2948f87ab28015f12359ca6a0"}, - {file = "Brotli-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9011560a466d2eb3f5a6e4929cf4a09be405c64154e12df0dd72713f6500e32b"}, - {file = "Brotli-1.1.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a090ca607cbb6a34b0391776f0cb48062081f5f60ddcce5d11838e67a01928d1"}, - {file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2de9d02f5bda03d27ede52e8cfe7b865b066fa49258cbab568720aa5be80a47d"}, - {file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2333e30a5e00fe0fe55903c8832e08ee9c3b1382aacf4db26664a16528d51b4b"}, - {file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4d4a848d1837973bf0f4b5e54e3bec977d99be36a7895c61abb659301b02c112"}, - {file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:fdc3ff3bfccdc6b9cc7c342c03aa2400683f0cb891d46e94b64a197910dc4064"}, - {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:5eeb539606f18a0b232d4ba45adccde4125592f3f636a6182b4a8a436548b914"}, - {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:fd5f17ff8f14003595ab414e45fce13d073e0762394f957182e69035c9f3d7c2"}, - {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:069a121ac97412d1fe506da790b3e69f52254b9df4eb665cd42460c837193354"}, - {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:e93dfc1a1165e385cc8239fab7c036fb2cd8093728cbd85097b284d7b99249a2"}, - {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:aea440a510e14e818e67bfc4027880e2fb500c2ccb20ab21c7a7c8b5b4703d75"}, - {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_i686.whl", hash = "sha256:6974f52a02321b36847cd19d1b8e381bf39939c21efd6ee2fc13a28b0d99348c"}, - {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_ppc64le.whl", hash = "sha256:a7e53012d2853a07a4a79c00643832161a910674a893d296c9f1259859a289d2"}, - {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:d7702622a8b40c49bffb46e1e3ba2e81268d5c04a34f460978c6b5517a34dd52"}, - {file = "Brotli-1.1.0-cp36-cp36m-win32.whl", hash = "sha256:a599669fd7c47233438a56936988a2478685e74854088ef5293802123b5b2460"}, - {file = "Brotli-1.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:d143fd47fad1db3d7c27a1b1d66162e855b5d50a89666af46e1679c496e8e579"}, - {file = "Brotli-1.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:11d00ed0a83fa22d29bc6b64ef636c4552ebafcef57154b4ddd132f5638fbd1c"}, - {file = "Brotli-1.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f733d788519c7e3e71f0855c96618720f5d3d60c3cb829d8bbb722dddce37985"}, - {file = "Brotli-1.1.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:929811df5462e182b13920da56c6e0284af407d1de637d8e536c5cd00a7daf60"}, - {file = "Brotli-1.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b63b949ff929fbc2d6d3ce0e924c9b93c9785d877a21a1b678877ffbbc4423a"}, - {file = "Brotli-1.1.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:d192f0f30804e55db0d0e0a35d83a9fead0e9a359a9ed0285dbacea60cc10a84"}, - {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f296c40e23065d0d6650c4aefe7470d2a25fffda489bcc3eb66083f3ac9f6643"}, - {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:919e32f147ae93a09fe064d77d5ebf4e35502a8df75c29fb05788528e330fe74"}, - {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:23032ae55523cc7bccb4f6a0bf368cd25ad9bcdcc1990b64a647e7bbcce9cb5b"}, - {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:224e57f6eac61cc449f498cc5f0e1725ba2071a3d4f48d5d9dffba42db196438"}, - {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:cb1dac1770878ade83f2ccdf7d25e494f05c9165f5246b46a621cc849341dc01"}, - {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:3ee8a80d67a4334482d9712b8e83ca6b1d9bc7e351931252ebef5d8f7335a547"}, - {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:5e55da2c8724191e5b557f8e18943b1b4839b8efc3ef60d65985bcf6f587dd38"}, - {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:d342778ef319e1026af243ed0a07c97acf3bad33b9f29e7ae6a1f68fd083e90c"}, - {file = "Brotli-1.1.0-cp37-cp37m-win32.whl", hash = "sha256:587ca6d3cef6e4e868102672d3bd9dc9698c309ba56d41c2b9c85bbb903cdb95"}, - {file = "Brotli-1.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2954c1c23f81c2eaf0b0717d9380bd348578a94161a65b3a2afc62c86467dd68"}, - {file = "Brotli-1.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:efa8b278894b14d6da122a72fefcebc28445f2d3f880ac59d46c90f4c13be9a3"}, - {file = "Brotli-1.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:03d20af184290887bdea3f0f78c4f737d126c74dc2f3ccadf07e54ceca3bf208"}, - {file = "Brotli-1.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6172447e1b368dcbc458925e5ddaf9113477b0ed542df258d84fa28fc45ceea7"}, - {file = "Brotli-1.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a743e5a28af5f70f9c080380a5f908d4d21d40e8f0e0c8901604d15cfa9ba751"}, - {file = "Brotli-1.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0541e747cce78e24ea12d69176f6a7ddb690e62c425e01d31cc065e69ce55b48"}, - {file = "Brotli-1.1.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:cdbc1fc1bc0bff1cef838eafe581b55bfbffaed4ed0318b724d0b71d4d377619"}, - {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:890b5a14ce214389b2cc36ce82f3093f96f4cc730c1cffdbefff77a7c71f2a97"}, - {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ab4fbee0b2d9098c74f3057b2bc055a8bd92ccf02f65944a241b4349229185a"}, - {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:141bd4d93984070e097521ed07e2575b46f817d08f9fa42b16b9b5f27b5ac088"}, - {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fce1473f3ccc4187f75b4690cfc922628aed4d3dd013d047f95a9b3919a86596"}, - {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d2b35ca2c7f81d173d2fadc2f4f31e88cc5f7a39ae5b6db5513cf3383b0e0ec7"}, - {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:af6fa6817889314555aede9a919612b23739395ce767fe7fcbea9a80bf140fe5"}, - {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:2feb1d960f760a575dbc5ab3b1c00504b24caaf6986e2dc2b01c09c87866a943"}, - {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4410f84b33374409552ac9b6903507cdb31cd30d2501fc5ca13d18f73548444a"}, - {file = "Brotli-1.1.0-cp38-cp38-win32.whl", hash = "sha256:db85ecf4e609a48f4b29055f1e144231b90edc90af7481aa731ba2d059226b1b"}, - {file = "Brotli-1.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3d7954194c36e304e1523f55d7042c59dc53ec20dd4e9ea9d151f1b62b4415c0"}, - {file = "Brotli-1.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5fb2ce4b8045c78ebbc7b8f3c15062e435d47e7393cc57c25115cfd49883747a"}, - {file = "Brotli-1.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7905193081db9bfa73b1219140b3d315831cbff0d8941f22da695832f0dd188f"}, - {file = "Brotli-1.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a77def80806c421b4b0af06f45d65a136e7ac0bdca3c09d9e2ea4e515367c7e9"}, - {file = "Brotli-1.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dadd1314583ec0bf2d1379f7008ad627cd6336625d6679cf2f8e67081b83acf"}, - {file = "Brotli-1.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:901032ff242d479a0efa956d853d16875d42157f98951c0230f69e69f9c09bac"}, - {file = "Brotli-1.1.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:22fc2a8549ffe699bfba2256ab2ed0421a7b8fadff114a3d201794e45a9ff578"}, - {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ae15b066e5ad21366600ebec29a7ccbc86812ed267e4b28e860b8ca16a2bc474"}, - {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:949f3b7c29912693cee0afcf09acd6ebc04c57af949d9bf77d6101ebb61e388c"}, - {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:89f4988c7203739d48c6f806f1e87a1d96e0806d44f0fba61dba81392c9e474d"}, - {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:de6551e370ef19f8de1807d0a9aa2cdfdce2e85ce88b122fe9f6b2b076837e59"}, - {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0737ddb3068957cf1b054899b0883830bb1fec522ec76b1098f9b6e0f02d9419"}, - {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4f3607b129417e111e30637af1b56f24f7a49e64763253bbc275c75fa887d4b2"}, - {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:6c6e0c425f22c1c719c42670d561ad682f7bfeeef918edea971a79ac5252437f"}, - {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:494994f807ba0b92092a163a0a283961369a65f6cbe01e8891132b7a320e61eb"}, - {file = "Brotli-1.1.0-cp39-cp39-win32.whl", hash = "sha256:f0d8a7a6b5983c2496e364b969f0e526647a06b075d034f3297dc66f3b360c64"}, - {file = "Brotli-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:cdad5b9014d83ca68c25d2e9444e28e967ef16e80f6b436918c700c117a85467"}, - {file = "Brotli-1.1.0.tar.gz", hash = "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724"}, -] - -[[package]] -name = "certifi" -version = "2023.11.17" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.6" -files = [ - {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, - {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, -] - -[[package]] -name = "cffi" -version = "1.17.1" -description = "Foreign Function Interface for Python calling C code." -optional = false -python-versions = ">=3.8" -files = [ - {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, - {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, - {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, - {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, - {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, - {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, - {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, - {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, - {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, - {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, - {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, - {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, - {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, - {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, - {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, - {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, - {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, - {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, - {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, - {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, - {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, - {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, - {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, - {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, - {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, - {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, - {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, - {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, -] - -[package.dependencies] -pycparser = "*" - -[[package]] -name = "charset-normalizer" -version = "3.3.2" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7.0" -files = [ - {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, - {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, -] - -[[package]] -name = "click" -version = "8.1.7" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.7" -files = [ - {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, - {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "coloredlogs" -version = "15.0.1" -description = "Colored terminal output for Python's logging module" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -files = [ - {file = "coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934"}, - {file = "coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0"}, -] - -[package.dependencies] -humanfriendly = ">=9.1" - -[package.extras] -cron = ["capturer (>=2.4)"] - -[[package]] -name = "configargparse" -version = "1.7" -description = "A drop-in replacement for argparse that allows options to also be set via config files and/or environment variables." -optional = false -python-versions = ">=3.5" -files = [ - {file = "ConfigArgParse-1.7-py3-none-any.whl", hash = "sha256:d249da6591465c6c26df64a9f73d2536e743be2f244eb3ebe61114af2f94f86b"}, - {file = "ConfigArgParse-1.7.tar.gz", hash = "sha256:e7067471884de5478c58a511e529f0f9bd1c66bfef1dea90935438d6c23306d1"}, -] - -[package.extras] -test = ["PyYAML", "mock", "pytest"] -yaml = ["PyYAML"] - -[[package]] -name = "contourpy" -version = "1.2.0" -description = "Python library for calculating contours of 2D quadrilateral grids" -optional = false -python-versions = ">=3.9" -files = [ - {file = "contourpy-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0274c1cb63625972c0c007ab14dd9ba9e199c36ae1a231ce45d725cbcbfd10a8"}, - {file = "contourpy-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ab459a1cbbf18e8698399c595a01f6dcc5c138220ca3ea9e7e6126232d102bb4"}, - {file = "contourpy-1.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fdd887f17c2f4572ce548461e4f96396681212d858cae7bd52ba3310bc6f00f"}, - {file = "contourpy-1.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5d16edfc3fc09968e09ddffada434b3bf989bf4911535e04eada58469873e28e"}, - {file = "contourpy-1.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c203f617abc0dde5792beb586f827021069fb6d403d7f4d5c2b543d87edceb9"}, - {file = "contourpy-1.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b69303ceb2e4d4f146bf82fda78891ef7bcd80c41bf16bfca3d0d7eb545448aa"}, - {file = "contourpy-1.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:884c3f9d42d7218304bc74a8a7693d172685c84bd7ab2bab1ee567b769696df9"}, - {file = "contourpy-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4a1b1208102be6e851f20066bf0e7a96b7d48a07c9b0cfe6d0d4545c2f6cadab"}, - {file = "contourpy-1.2.0-cp310-cp310-win32.whl", hash = "sha256:34b9071c040d6fe45d9826cbbe3727d20d83f1b6110d219b83eb0e2a01d79488"}, - {file = "contourpy-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:bd2f1ae63998da104f16a8b788f685e55d65760cd1929518fd94cd682bf03e41"}, - {file = "contourpy-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dd10c26b4eadae44783c45ad6655220426f971c61d9b239e6f7b16d5cdaaa727"}, - {file = "contourpy-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5c6b28956b7b232ae801406e529ad7b350d3f09a4fde958dfdf3c0520cdde0dd"}, - {file = "contourpy-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebeac59e9e1eb4b84940d076d9f9a6cec0064e241818bcb6e32124cc5c3e377a"}, - {file = "contourpy-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:139d8d2e1c1dd52d78682f505e980f592ba53c9f73bd6be102233e358b401063"}, - {file = "contourpy-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e9dc350fb4c58adc64df3e0703ab076f60aac06e67d48b3848c23647ae4310e"}, - {file = "contourpy-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18fc2b4ed8e4a8fe849d18dce4bd3c7ea637758c6343a1f2bae1e9bd4c9f4686"}, - {file = "contourpy-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:16a7380e943a6d52472096cb7ad5264ecee36ed60888e2a3d3814991a0107286"}, - {file = "contourpy-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8d8faf05be5ec8e02a4d86f616fc2a0322ff4a4ce26c0f09d9f7fb5330a35c95"}, - {file = "contourpy-1.2.0-cp311-cp311-win32.whl", hash = "sha256:67b7f17679fa62ec82b7e3e611c43a016b887bd64fb933b3ae8638583006c6d6"}, - {file = "contourpy-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:99ad97258985328b4f207a5e777c1b44a83bfe7cf1f87b99f9c11d4ee477c4de"}, - {file = "contourpy-1.2.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:575bcaf957a25d1194903a10bc9f316c136c19f24e0985a2b9b5608bdf5dbfe0"}, - {file = "contourpy-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9e6c93b5b2dbcedad20a2f18ec22cae47da0d705d454308063421a3b290d9ea4"}, - {file = "contourpy-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:464b423bc2a009088f19bdf1f232299e8b6917963e2b7e1d277da5041f33a779"}, - {file = "contourpy-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:68ce4788b7d93e47f84edd3f1f95acdcd142ae60bc0e5493bfd120683d2d4316"}, - {file = "contourpy-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d7d1f8871998cdff5d2ff6a087e5e1780139abe2838e85b0b46b7ae6cc25399"}, - {file = "contourpy-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e739530c662a8d6d42c37c2ed52a6f0932c2d4a3e8c1f90692ad0ce1274abe0"}, - {file = "contourpy-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:247b9d16535acaa766d03037d8e8fb20866d054d3c7fbf6fd1f993f11fc60ca0"}, - {file = "contourpy-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:461e3ae84cd90b30f8d533f07d87c00379644205b1d33a5ea03381edc4b69431"}, - {file = "contourpy-1.2.0-cp312-cp312-win32.whl", hash = "sha256:1c2559d6cffc94890b0529ea7eeecc20d6fadc1539273aa27faf503eb4656d8f"}, - {file = "contourpy-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:491b1917afdd8638a05b611a56d46587d5a632cabead889a5440f7c638bc6ed9"}, - {file = "contourpy-1.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5fd1810973a375ca0e097dee059c407913ba35723b111df75671a1976efa04bc"}, - {file = "contourpy-1.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:999c71939aad2780f003979b25ac5b8f2df651dac7b38fb8ce6c46ba5abe6ae9"}, - {file = "contourpy-1.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7caf9b241464c404613512d5594a6e2ff0cc9cb5615c9475cc1d9b514218ae8"}, - {file = "contourpy-1.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:266270c6f6608340f6c9836a0fb9b367be61dde0c9a9a18d5ece97774105ff3e"}, - {file = "contourpy-1.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbd50d0a0539ae2e96e537553aff6d02c10ed165ef40c65b0e27e744a0f10af8"}, - {file = "contourpy-1.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11f8d2554e52f459918f7b8e6aa20ec2a3bce35ce95c1f0ef4ba36fbda306df5"}, - {file = "contourpy-1.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ce96dd400486e80ac7d195b2d800b03e3e6a787e2a522bfb83755938465a819e"}, - {file = "contourpy-1.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6d3364b999c62f539cd403f8123ae426da946e142312a514162adb2addd8d808"}, - {file = "contourpy-1.2.0-cp39-cp39-win32.whl", hash = "sha256:1c88dfb9e0c77612febebb6ac69d44a8d81e3dc60f993215425b62c1161353f4"}, - {file = "contourpy-1.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:78e6ad33cf2e2e80c5dfaaa0beec3d61face0fb650557100ee36db808bfa6843"}, - {file = "contourpy-1.2.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:be16975d94c320432657ad2402f6760990cb640c161ae6da1363051805fa8108"}, - {file = "contourpy-1.2.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b95a225d4948b26a28c08307a60ac00fb8671b14f2047fc5476613252a129776"}, - {file = "contourpy-1.2.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:0d7e03c0f9a4f90dc18d4e77e9ef4ec7b7bbb437f7f675be8e530d65ae6ef956"}, - {file = "contourpy-1.2.0.tar.gz", hash = "sha256:171f311cb758de7da13fc53af221ae47a5877be5a0843a9fe150818c51ed276a"}, -] - -[package.dependencies] -numpy = ">=1.20,<2.0" - -[package.extras] -bokeh = ["bokeh", "selenium"] -docs = ["furo", "sphinx (>=7.2)", "sphinx-copybutton"] -mypy = ["contourpy[bokeh,docs]", "docutils-stubs", "mypy (==1.6.1)", "types-Pillow"] -test = ["Pillow", "contourpy[test-no-images]", "matplotlib"] -test-no-images = ["pytest", "pytest-cov", "pytest-xdist", "wurlitzer"] - -[[package]] -name = "coverage" -version = "7.6.4" -description = "Code coverage measurement for Python" -optional = false -python-versions = ">=3.9" -files = [ - {file = "coverage-7.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f8ae553cba74085db385d489c7a792ad66f7f9ba2ee85bfa508aeb84cf0ba07"}, - {file = "coverage-7.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8165b796df0bd42e10527a3f493c592ba494f16ef3c8b531288e3d0d72c1f6f0"}, - {file = "coverage-7.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7c8b95bf47db6d19096a5e052ffca0a05f335bc63cef281a6e8fe864d450a72"}, - {file = "coverage-7.6.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ed9281d1b52628e81393f5eaee24a45cbd64965f41857559c2b7ff19385df51"}, - {file = "coverage-7.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0809082ee480bb8f7416507538243c8863ac74fd8a5d2485c46f0f7499f2b491"}, - {file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d541423cdd416b78626b55f123412fcf979d22a2c39fce251b350de38c15c15b"}, - {file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58809e238a8a12a625c70450b48e8767cff9eb67c62e6154a642b21ddf79baea"}, - {file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c9b8e184898ed014884ca84c70562b4a82cbc63b044d366fedc68bc2b2f3394a"}, - {file = "coverage-7.6.4-cp310-cp310-win32.whl", hash = "sha256:6bd818b7ea14bc6e1f06e241e8234508b21edf1b242d49831831a9450e2f35fa"}, - {file = "coverage-7.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:06babbb8f4e74b063dbaeb74ad68dfce9186c595a15f11f5d5683f748fa1d172"}, - {file = "coverage-7.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:73d2b73584446e66ee633eaad1a56aad577c077f46c35ca3283cd687b7715b0b"}, - {file = "coverage-7.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:51b44306032045b383a7a8a2c13878de375117946d68dcb54308111f39775a25"}, - {file = "coverage-7.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b3fb02fe73bed561fa12d279a417b432e5b50fe03e8d663d61b3d5990f29546"}, - {file = "coverage-7.6.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed8fe9189d2beb6edc14d3ad19800626e1d9f2d975e436f84e19efb7fa19469b"}, - {file = "coverage-7.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b369ead6527d025a0fe7bd3864e46dbee3aa8f652d48df6174f8d0bac9e26e0e"}, - {file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ade3ca1e5f0ff46b678b66201f7ff477e8fa11fb537f3b55c3f0568fbfe6e718"}, - {file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:27fb4a050aaf18772db513091c9c13f6cb94ed40eacdef8dad8411d92d9992db"}, - {file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f704f0998911abf728a7783799444fcbbe8261c4a6c166f667937ae6a8aa522"}, - {file = "coverage-7.6.4-cp311-cp311-win32.whl", hash = "sha256:29155cd511ee058e260db648b6182c419422a0d2e9a4fa44501898cf918866cf"}, - {file = "coverage-7.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:8902dd6a30173d4ef09954bfcb24b5d7b5190cf14a43170e386979651e09ba19"}, - {file = "coverage-7.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12394842a3a8affa3ba62b0d4ab7e9e210c5e366fbac3e8b2a68636fb19892c2"}, - {file = "coverage-7.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b6b4c83d8e8ea79f27ab80778c19bc037759aea298da4b56621f4474ffeb117"}, - {file = "coverage-7.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d5b8007f81b88696d06f7df0cb9af0d3b835fe0c8dbf489bad70b45f0e45613"}, - {file = "coverage-7.6.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b57b768feb866f44eeed9f46975f3d6406380275c5ddfe22f531a2bf187eda27"}, - {file = "coverage-7.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5915fcdec0e54ee229926868e9b08586376cae1f5faa9bbaf8faf3561b393d52"}, - {file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b58c672d14f16ed92a48db984612f5ce3836ae7d72cdd161001cc54512571f2"}, - {file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2fdef0d83a2d08d69b1f2210a93c416d54e14d9eb398f6ab2f0a209433db19e1"}, - {file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cf717ee42012be8c0cb205dbbf18ffa9003c4cbf4ad078db47b95e10748eec5"}, - {file = "coverage-7.6.4-cp312-cp312-win32.whl", hash = "sha256:7bb92c539a624cf86296dd0c68cd5cc286c9eef2d0c3b8b192b604ce9de20a17"}, - {file = "coverage-7.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:1032e178b76a4e2b5b32e19d0fd0abbce4b58e77a1ca695820d10e491fa32b08"}, - {file = "coverage-7.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:023bf8ee3ec6d35af9c1c6ccc1d18fa69afa1cb29eaac57cb064dbb262a517f9"}, - {file = "coverage-7.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0ac3d42cb51c4b12df9c5f0dd2f13a4f24f01943627120ec4d293c9181219ba"}, - {file = "coverage-7.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8fe4984b431f8621ca53d9380901f62bfb54ff759a1348cd140490ada7b693c"}, - {file = "coverage-7.6.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5fbd612f8a091954a0c8dd4c0b571b973487277d26476f8480bfa4b2a65b5d06"}, - {file = "coverage-7.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dacbc52de979f2823a819571f2e3a350a7e36b8cb7484cdb1e289bceaf35305f"}, - {file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dab4d16dfef34b185032580e2f2f89253d302facba093d5fa9dbe04f569c4f4b"}, - {file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:862264b12ebb65ad8d863d51f17758b1684560b66ab02770d4f0baf2ff75da21"}, - {file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5beb1ee382ad32afe424097de57134175fea3faf847b9af002cc7895be4e2a5a"}, - {file = "coverage-7.6.4-cp313-cp313-win32.whl", hash = "sha256:bf20494da9653f6410213424f5f8ad0ed885e01f7e8e59811f572bdb20b8972e"}, - {file = "coverage-7.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:182e6cd5c040cec0a1c8d415a87b67ed01193ed9ad458ee427741c7d8513d963"}, - {file = "coverage-7.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a181e99301a0ae128493a24cfe5cfb5b488c4e0bf2f8702091473d033494d04f"}, - {file = "coverage-7.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:df57bdbeffe694e7842092c5e2e0bc80fff7f43379d465f932ef36f027179806"}, - {file = "coverage-7.6.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bcd1069e710600e8e4cf27f65c90c7843fa8edfb4520fb0ccb88894cad08b11"}, - {file = "coverage-7.6.4-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99b41d18e6b2a48ba949418db48159d7a2e81c5cc290fc934b7d2380515bd0e3"}, - {file = "coverage-7.6.4-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1e54712ba3474f34b7ef7a41e65bd9037ad47916ccb1cc78769bae324c01a"}, - {file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:53d202fd109416ce011578f321460795abfe10bb901b883cafd9b3ef851bacfc"}, - {file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:c48167910a8f644671de9f2083a23630fbf7a1cb70ce939440cd3328e0919f70"}, - {file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cc8ff50b50ce532de2fa7a7daae9dd12f0a699bfcd47f20945364e5c31799fef"}, - {file = "coverage-7.6.4-cp313-cp313t-win32.whl", hash = "sha256:b8d3a03d9bfcaf5b0141d07a88456bb6a4c3ce55c080712fec8418ef3610230e"}, - {file = "coverage-7.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:f3ddf056d3ebcf6ce47bdaf56142af51bb7fad09e4af310241e9db7a3a8022e1"}, - {file = "coverage-7.6.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9cb7fa111d21a6b55cbf633039f7bc2749e74932e3aa7cb7333f675a58a58bf3"}, - {file = "coverage-7.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:11a223a14e91a4693d2d0755c7a043db43d96a7450b4f356d506c2562c48642c"}, - {file = "coverage-7.6.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a413a096c4cbac202433c850ee43fa326d2e871b24554da8327b01632673a076"}, - {file = "coverage-7.6.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00a1d69c112ff5149cabe60d2e2ee948752c975d95f1e1096742e6077affd376"}, - {file = "coverage-7.6.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f76846299ba5c54d12c91d776d9605ae33f8ae2b9d1d3c3703cf2db1a67f2c0"}, - {file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fe439416eb6380de434886b00c859304338f8b19f6f54811984f3420a2e03858"}, - {file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0294ca37f1ba500667b1aef631e48d875ced93ad5e06fa665a3295bdd1d95111"}, - {file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6f01ba56b1c0e9d149f9ac85a2f999724895229eb36bd997b61e62999e9b0901"}, - {file = "coverage-7.6.4-cp39-cp39-win32.whl", hash = "sha256:bc66f0bf1d7730a17430a50163bb264ba9ded56739112368ba985ddaa9c3bd09"}, - {file = "coverage-7.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:c481b47f6b5845064c65a7bc78bc0860e635a9b055af0df46fdf1c58cebf8e8f"}, - {file = "coverage-7.6.4-pp39.pp310-none-any.whl", hash = "sha256:3c65d37f3a9ebb703e710befdc489a38683a5b152242664b973a7b7b22348a4e"}, - {file = "coverage-7.6.4.tar.gz", hash = "sha256:29fc0f17b1d3fea332f8001d4558f8214af7f1d87a345f3a133c901d60347c73"}, -] - -[package.dependencies] -tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} - -[package.extras] -toml = ["tomli"] - -[[package]] -name = "cycler" -version = "0.12.1" -description = "Composable style cycles" -optional = false -python-versions = ">=3.8" -files = [ - {file = "cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30"}, - {file = "cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c"}, -] - -[package.extras] -docs = ["ipython", "matplotlib", "numpydoc", "sphinx"] -tests = ["pytest", "pytest-cov", "pytest-xdist"] - -[[package]] -name = "cython" -version = "3.0.8" -description = "The Cython compiler for writing C extensions in the Python language." -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "Cython-3.0.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a846e0a38e2b24e9a5c5dc74b0e54c6e29420d88d1dafabc99e0fc0f3e338636"}, - {file = "Cython-3.0.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45523fdc2b78d79b32834cc1cc12dc2ca8967af87e22a3ee1bff20e77c7f5520"}, - {file = "Cython-3.0.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa0b7f3f841fe087410cab66778e2d3fb20ae2d2078a2be3dffe66c6574be39"}, - {file = "Cython-3.0.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e87294e33e40c289c77a135f491cd721bd089f193f956f7b8ed5aa2d0b8c558f"}, - {file = "Cython-3.0.8-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a1df7a129344b1215c20096d33c00193437df1a8fcca25b71f17c23b1a44f782"}, - {file = "Cython-3.0.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:13c2a5e57a0358da467d97667297bf820b62a1a87ae47c5f87938b9bb593acbd"}, - {file = "Cython-3.0.8-cp310-cp310-win32.whl", hash = "sha256:96b028f044f5880e3cb18ecdcfc6c8d3ce9d0af28418d5ab464509f26d8adf12"}, - {file = "Cython-3.0.8-cp310-cp310-win_amd64.whl", hash = "sha256:8140597a8b5cc4f119a1190f5a2228a84f5ca6d8d9ec386cfce24663f48b2539"}, - {file = "Cython-3.0.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aae26f9663e50caf9657148403d9874eea41770ecdd6caf381d177c2b1bb82ba"}, - {file = "Cython-3.0.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:547eb3cdb2f8c6f48e6865d5a741d9dd051c25b3ce076fbca571727977b28ac3"}, - {file = "Cython-3.0.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a567d4b9ba70b26db89d75b243529de9e649a2f56384287533cf91512705bee"}, - {file = "Cython-3.0.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:51d1426263b0e82fb22bda8ea60dc77a428581cc19e97741011b938445d383f1"}, - {file = "Cython-3.0.8-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c26daaeccda072459b48d211415fd1e5507c06bcd976fa0d5b8b9f1063467d7b"}, - {file = "Cython-3.0.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:289ce7838208211cd166e975865fd73b0649bf118170b6cebaedfbdaf4a37795"}, - {file = "Cython-3.0.8-cp311-cp311-win32.whl", hash = "sha256:c8aa05f5e17f8042a3be052c24f2edc013fb8af874b0bf76907d16c51b4e7871"}, - {file = "Cython-3.0.8-cp311-cp311-win_amd64.whl", hash = "sha256:000dc9e135d0eec6ecb2b40a5b02d0868a2f8d2e027a41b0fe16a908a9e6de02"}, - {file = "Cython-3.0.8-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:90d3fe31db55685d8cb97d43b0ec39ef614fcf660f83c77ed06aa670cb0e164f"}, - {file = "Cython-3.0.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e24791ddae2324e88e3c902a765595c738f19ae34ee66bfb1a6dac54b1833419"}, - {file = "Cython-3.0.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f020fa1c0552052e0660790b8153b79e3fc9a15dbd8f1d0b841fe5d204a6ae6"}, - {file = "Cython-3.0.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18bfa387d7a7f77d7b2526af69a65dbd0b731b8d941aaff5becff8e21f6d7717"}, - {file = "Cython-3.0.8-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fe81b339cffd87c0069c6049b4d33e28bdd1874625ee515785bf42c9fdff3658"}, - {file = "Cython-3.0.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:80fd94c076e1e1b1ee40a309be03080b75f413e8997cddcf401a118879863388"}, - {file = "Cython-3.0.8-cp312-cp312-win32.whl", hash = "sha256:85077915a93e359a9b920280d214dc0cf8a62773e1f3d7d30fab8ea4daed670c"}, - {file = "Cython-3.0.8-cp312-cp312-win_amd64.whl", hash = "sha256:0cb2dcc565c7851f75d496f724a384a790fab12d1b82461b663e66605bec429a"}, - {file = "Cython-3.0.8-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:870d2a0a7e3cbd5efa65aecdb38d715ea337a904ea7bb22324036e78fb7068e7"}, - {file = "Cython-3.0.8-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7e8f2454128974905258d86534f4fd4f91d2f1343605657ecab779d80c9d6d5e"}, - {file = "Cython-3.0.8-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1949d6aa7bc792554bee2b67a9fe41008acbfe22f4f8df7b6ec7b799613a4b3"}, - {file = "Cython-3.0.8-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9f2c6e1b8f3bcd6cb230bac1843f85114780bb8be8614855b1628b36bb510e0"}, - {file = "Cython-3.0.8-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:05d7eddc668ae7993643f32c7661f25544e791edb745758672ea5b1a82ecffa6"}, - {file = "Cython-3.0.8-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bfabe115deef4ada5d23c87bddb11289123336dcc14347011832c07db616dd93"}, - {file = "Cython-3.0.8-cp36-cp36m-win32.whl", hash = "sha256:0c38c9f0bcce2df0c3347285863621be904ac6b64c5792d871130569d893efd7"}, - {file = "Cython-3.0.8-cp36-cp36m-win_amd64.whl", hash = "sha256:6c46939c3983217d140999de7c238c3141f56b1ea349e47ca49cae899969aa2c"}, - {file = "Cython-3.0.8-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:115f0a50f752da6c99941b103b5cb090da63eb206abbc7c2ad33856ffc73f064"}, - {file = "Cython-3.0.8-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9c0f29246734561c90f36e70ed0506b61aa3d044e4cc4cba559065a2a741fae"}, - {file = "Cython-3.0.8-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ab75242869ff71e5665fe5c96f3378e79e792fa3c11762641b6c5afbbbbe026"}, - {file = "Cython-3.0.8-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6717c06e9cfc6c1df18543cd31a21f5d8e378a40f70c851fa2d34f0597037abc"}, - {file = "Cython-3.0.8-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:9d3f74388db378a3c6fd06e79a809ed98df3f56484d317b81ee762dbf3c263e0"}, - {file = "Cython-3.0.8-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ae7ac561fd8253a9ae96311e91d12af5f701383564edc11d6338a7b60b285a6f"}, - {file = "Cython-3.0.8-cp37-cp37m-win32.whl", hash = "sha256:97b2a45845b993304f1799664fa88da676ee19442b15fdcaa31f9da7e1acc434"}, - {file = "Cython-3.0.8-cp37-cp37m-win_amd64.whl", hash = "sha256:9e2be2b340fea46fb849d378f9b80d3c08ff2e81e2bfbcdb656e2e3cd8c6b2dc"}, - {file = "Cython-3.0.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2cde23c555470db3f149ede78b518e8274853745289c956a0e06ad8d982e4db9"}, - {file = "Cython-3.0.8-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7990ca127e1f1beedaf8fc8bf66541d066ef4723ad7d8d47a7cbf842e0f47580"}, - {file = "Cython-3.0.8-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b983c8e6803f016146c26854d9150ddad5662960c804ea7f0c752c9266752f0"}, - {file = "Cython-3.0.8-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a973268d7ca1a2bdf78575e459a94a78e1a0a9bb62a7db0c50041949a73b02ff"}, - {file = "Cython-3.0.8-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:61a237bc9dd23c7faef0fcfce88c11c65d0c9bb73c74ccfa408b3a012073c20e"}, - {file = "Cython-3.0.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:3a3d67f079598af49e90ff9655bf85bd358f093d727eb21ca2708f467c489cae"}, - {file = "Cython-3.0.8-cp38-cp38-win32.whl", hash = "sha256:17a642bb01a693e34c914106566f59844b4461665066613913463a719e0dd15d"}, - {file = "Cython-3.0.8-cp38-cp38-win_amd64.whl", hash = "sha256:2cdfc32252f3b6dc7c94032ab744dcedb45286733443c294d8f909a4854e7f83"}, - {file = "Cython-3.0.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa97893d99385386925d00074654aeae3a98867f298d1e12ceaf38a9054a9bae"}, - {file = "Cython-3.0.8-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f05c0bf9d085c031df8f583f0d506aa3be1692023de18c45d0aaf78685bbb944"}, - {file = "Cython-3.0.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de892422582f5758bd8de187e98ac829330ec1007bc42c661f687792999988a7"}, - {file = "Cython-3.0.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:314f2355a1f1d06e3c431eaad4708cf10037b5e91e4b231d89c913989d0bdafd"}, - {file = "Cython-3.0.8-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:78825a3774211e7d5089730f00cdf7f473042acc9ceb8b9eeebe13ed3a5541de"}, - {file = "Cython-3.0.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:df8093deabc55f37028190cf5e575c26aad23fc673f34b85d5f45076bc37ce39"}, - {file = "Cython-3.0.8-cp39-cp39-win32.whl", hash = "sha256:1aca1b97e0095b3a9a6c33eada3f661a4ed0d499067d121239b193e5ba3bb4f0"}, - {file = "Cython-3.0.8-cp39-cp39-win_amd64.whl", hash = "sha256:16873d78be63bd38ffb759da7ab82814b36f56c769ee02b1d5859560e4c3ac3c"}, - {file = "Cython-3.0.8-py2.py3-none-any.whl", hash = "sha256:171b27051253d3f9108e9759e504ba59ff06e7f7ba944457f94deaf9c21bf0b6"}, - {file = "Cython-3.0.8.tar.gz", hash = "sha256:8333423d8fd5765e7cceea3a9985dd1e0a5dfeb2734629e1a2ed2d6233d39de6"}, -] - -[[package]] -name = "easydict" -version = "1.11" -description = "Access dict values as attributes (works recursively)." -optional = false -python-versions = "*" -files = [ - {file = "easydict-1.11.tar.gz", hash = "sha256:dcb1d2ed28eb300c8e46cd371340373abc62f7c14d6dea74fdfc6f1069061c78"}, -] - -[[package]] -name = "exceptiongroup" -version = "1.2.0" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -files = [ - {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, - {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, -] - -[package.extras] -test = ["pytest (>=6)"] - -[[package]] -name = "fastapi" -version = "0.115.6" -description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" -optional = false -python-versions = ">=3.8" -files = [ - {file = "fastapi-0.115.6-py3-none-any.whl", hash = "sha256:e9240b29e36fa8f4bb7290316988e90c381e5092e0cbe84e7818cc3713bcf305"}, - {file = "fastapi-0.115.6.tar.gz", hash = "sha256:9ec46f7addc14ea472958a96aae5b5de65f39721a46aaf5705c480d9a8b76654"}, -] - -[package.dependencies] -pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" -starlette = ">=0.40.0,<0.42.0" -typing-extensions = ">=4.8.0" - -[package.extras] -all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] -standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=2.11.2)", "python-multipart (>=0.0.7)", "uvicorn[standard] (>=0.12.0)"] - -[[package]] -name = "filelock" -version = "3.13.1" -description = "A platform independent file lock." -optional = false -python-versions = ">=3.8" -files = [ - {file = "filelock-3.13.1-py3-none-any.whl", hash = "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c"}, - {file = "filelock-3.13.1.tar.gz", hash = "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e"}, -] - -[package.extras] -docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.24)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] -typing = ["typing-extensions (>=4.8)"] - -[[package]] -name = "flask" -version = "3.0.0" -description = "A simple framework for building complex web applications." -optional = false -python-versions = ">=3.8" -files = [ - {file = "flask-3.0.0-py3-none-any.whl", hash = "sha256:21128f47e4e3b9d597a3e8521a329bf56909b690fcc3fa3e477725aa81367638"}, - {file = "flask-3.0.0.tar.gz", hash = "sha256:cfadcdb638b609361d29ec22360d6070a77d7463dcb3ab08d2c2f2f168845f58"}, -] - -[package.dependencies] -blinker = ">=1.6.2" -click = ">=8.1.3" -itsdangerous = ">=2.1.2" -Jinja2 = ">=3.1.2" -Werkzeug = ">=3.0.0" - -[package.extras] -async = ["asgiref (>=3.2)"] -dotenv = ["python-dotenv"] - -[[package]] -name = "flask-cors" -version = "4.0.1" -description = "A Flask extension adding a decorator for CORS support" -optional = false -python-versions = "*" -files = [ - {file = "Flask_Cors-4.0.1-py2.py3-none-any.whl", hash = "sha256:f2a704e4458665580c074b714c4627dd5a306b333deb9074d0b1794dfa2fb677"}, - {file = "flask_cors-4.0.1.tar.gz", hash = "sha256:eeb69b342142fdbf4766ad99357a7f3876a2ceb77689dc10ff912aac06c389e4"}, -] - -[package.dependencies] -Flask = ">=0.9" - -[[package]] -name = "flask-login" -version = "0.6.3" -description = "User authentication and session management for Flask." -optional = false -python-versions = ">=3.7" -files = [ - {file = "Flask-Login-0.6.3.tar.gz", hash = "sha256:5e23d14a607ef12806c699590b89d0f0e0d67baeec599d75947bf9c147330333"}, - {file = "Flask_Login-0.6.3-py3-none-any.whl", hash = "sha256:849b25b82a436bf830a054e74214074af59097171562ab10bfa999e6b78aae5d"}, -] - -[package.dependencies] -Flask = ">=1.0.4" -Werkzeug = ">=1.0.1" - -[[package]] -name = "flatbuffers" -version = "23.5.26" -description = "The FlatBuffers serialization format for Python" -optional = false -python-versions = "*" -files = [ - {file = "flatbuffers-23.5.26-py2.py3-none-any.whl", hash = "sha256:c0ff356da363087b915fde4b8b45bdda73432fc17cddb3c8157472eab1422ad1"}, - {file = "flatbuffers-23.5.26.tar.gz", hash = "sha256:9ea1144cac05ce5d86e2859f431c6cd5e66cd9c78c558317c7955fb8d4c78d89"}, -] - -[[package]] -name = "fonttools" -version = "4.47.2" -description = "Tools to manipulate font files" -optional = false -python-versions = ">=3.8" -files = [ - {file = "fonttools-4.47.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3b629108351d25512d4ea1a8393a2dba325b7b7d7308116b605ea3f8e1be88df"}, - {file = "fonttools-4.47.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c19044256c44fe299d9a73456aabee4b4d06c6b930287be93b533b4737d70aa1"}, - {file = "fonttools-4.47.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8be28c036b9f186e8c7eaf8a11b42373e7e4949f9e9f370202b9da4c4c3f56c"}, - {file = "fonttools-4.47.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f83a4daef6d2a202acb9bf572958f91cfde5b10c8ee7fb1d09a4c81e5d851fd8"}, - {file = "fonttools-4.47.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4a5a5318ba5365d992666ac4fe35365f93004109d18858a3e18ae46f67907670"}, - {file = "fonttools-4.47.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8f57ecd742545362a0f7186774b2d1c53423ed9ece67689c93a1055b236f638c"}, - {file = "fonttools-4.47.2-cp310-cp310-win32.whl", hash = "sha256:a1c154bb85dc9a4cf145250c88d112d88eb414bad81d4cb524d06258dea1bdc0"}, - {file = "fonttools-4.47.2-cp310-cp310-win_amd64.whl", hash = "sha256:3e2b95dce2ead58fb12524d0ca7d63a63459dd489e7e5838c3cd53557f8933e1"}, - {file = "fonttools-4.47.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:29495d6d109cdbabe73cfb6f419ce67080c3ef9ea1e08d5750240fd4b0c4763b"}, - {file = "fonttools-4.47.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0a1d313a415eaaba2b35d6cd33536560deeebd2ed758b9bfb89ab5d97dc5deac"}, - {file = "fonttools-4.47.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90f898cdd67f52f18049250a6474185ef6544c91f27a7bee70d87d77a8daf89c"}, - {file = "fonttools-4.47.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3480eeb52770ff75140fe7d9a2ec33fb67b07efea0ab5129c7e0c6a639c40c70"}, - {file = "fonttools-4.47.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0255dbc128fee75fb9be364806b940ed450dd6838672a150d501ee86523ac61e"}, - {file = "fonttools-4.47.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f791446ff297fd5f1e2247c188de53c1bfb9dd7f0549eba55b73a3c2087a2703"}, - {file = "fonttools-4.47.2-cp311-cp311-win32.whl", hash = "sha256:740947906590a878a4bde7dd748e85fefa4d470a268b964748403b3ab2aeed6c"}, - {file = "fonttools-4.47.2-cp311-cp311-win_amd64.whl", hash = "sha256:63fbed184979f09a65aa9c88b395ca539c94287ba3a364517698462e13e457c9"}, - {file = "fonttools-4.47.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:4ec558c543609e71b2275c4894e93493f65d2f41c15fe1d089080c1d0bb4d635"}, - {file = "fonttools-4.47.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e040f905d542362e07e72e03612a6270c33d38281fd573160e1003e43718d68d"}, - {file = "fonttools-4.47.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6dd58cc03016b281bd2c74c84cdaa6bd3ce54c5a7f47478b7657b930ac3ed8eb"}, - {file = "fonttools-4.47.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32ab2e9702dff0dd4510c7bb958f265a8d3dd5c0e2547e7b5f7a3df4979abb07"}, - {file = "fonttools-4.47.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a808f3c1d1df1f5bf39be869b6e0c263570cdafb5bdb2df66087733f566ea71"}, - {file = "fonttools-4.47.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ac71e2e201df041a2891067dc36256755b1229ae167edbdc419b16da78732c2f"}, - {file = "fonttools-4.47.2-cp312-cp312-win32.whl", hash = "sha256:69731e8bea0578b3c28fdb43dbf95b9386e2d49a399e9a4ad736b8e479b08085"}, - {file = "fonttools-4.47.2-cp312-cp312-win_amd64.whl", hash = "sha256:b3e1304e5f19ca861d86a72218ecce68f391646d85c851742d265787f55457a4"}, - {file = "fonttools-4.47.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:254d9a6f7be00212bf0c3159e0a420eb19c63793b2c05e049eb337f3023c5ecc"}, - {file = "fonttools-4.47.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:eabae77a07c41ae0b35184894202305c3ad211a93b2eb53837c2a1143c8bc952"}, - {file = "fonttools-4.47.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a86a5ab2873ed2575d0fcdf1828143cfc6b977ac448e3dc616bb1e3d20efbafa"}, - {file = "fonttools-4.47.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13819db8445a0cec8c3ff5f243af6418ab19175072a9a92f6cc8ca7d1452754b"}, - {file = "fonttools-4.47.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4e743935139aa485fe3253fc33fe467eab6ea42583fa681223ea3f1a93dd01e6"}, - {file = "fonttools-4.47.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d49ce3ea7b7173faebc5664872243b40cf88814ca3eb135c4a3cdff66af71946"}, - {file = "fonttools-4.47.2-cp38-cp38-win32.whl", hash = "sha256:94208ea750e3f96e267f394d5588579bb64cc628e321dbb1d4243ffbc291b18b"}, - {file = "fonttools-4.47.2-cp38-cp38-win_amd64.whl", hash = "sha256:0f750037e02beb8b3569fbff701a572e62a685d2a0e840d75816592280e5feae"}, - {file = "fonttools-4.47.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3d71606c9321f6701642bd4746f99b6089e53d7e9817fc6b964e90d9c5f0ecc6"}, - {file = "fonttools-4.47.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:86e0427864c6c91cf77f16d1fb9bf1bbf7453e824589e8fb8461b6ee1144f506"}, - {file = "fonttools-4.47.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a00bd0e68e88987dcc047ea31c26d40a3c61185153b03457956a87e39d43c37"}, - {file = "fonttools-4.47.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5d77479fb885ef38a16a253a2f4096bc3d14e63a56d6246bfdb56365a12b20c"}, - {file = "fonttools-4.47.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5465df494f20a7d01712b072ae3ee9ad2887004701b95cb2cc6dcb9c2c97a899"}, - {file = "fonttools-4.47.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4c811d3c73b6abac275babb8aa439206288f56fdb2c6f8835e3d7b70de8937a7"}, - {file = "fonttools-4.47.2-cp39-cp39-win32.whl", hash = "sha256:5b60e3afa9635e3dfd3ace2757039593e3bd3cf128be0ddb7a1ff4ac45fa5a50"}, - {file = "fonttools-4.47.2-cp39-cp39-win_amd64.whl", hash = "sha256:7ee48bd9d6b7e8f66866c9090807e3a4a56cf43ffad48962725a190e0dd774c8"}, - {file = "fonttools-4.47.2-py3-none-any.whl", hash = "sha256:7eb7ad665258fba68fd22228a09f347469d95a97fb88198e133595947a20a184"}, - {file = "fonttools-4.47.2.tar.gz", hash = "sha256:7df26dd3650e98ca45f1e29883c96a0b9f5bb6af8d632a6a108bc744fa0bd9b3"}, -] - -[package.extras] -all = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "fs (>=2.2.0,<3)", "lxml (>=4.0,<5)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres", "pycairo", "scipy", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.23.0)", "unicodedata2 (>=15.1.0)", "xattr", "zopfli (>=0.1.4)"] -graphite = ["lz4 (>=1.7.4.2)"] -interpolatable = ["munkres", "pycairo", "scipy"] -lxml = ["lxml (>=4.0,<5)"] -pathops = ["skia-pathops (>=0.5.0)"] -plot = ["matplotlib"] -repacker = ["uharfbuzz (>=0.23.0)"] -symfont = ["sympy"] -type1 = ["xattr"] -ufo = ["fs (>=2.2.0,<3)"] -unicode = ["unicodedata2 (>=15.1.0)"] -woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"] - -[[package]] -name = "fsspec" -version = "2023.12.2" -description = "File-system specification" -optional = false -python-versions = ">=3.8" -files = [ - {file = "fsspec-2023.12.2-py3-none-any.whl", hash = "sha256:d800d87f72189a745fa3d6b033b9dc4a34ad069f60ca60b943a63599f5501960"}, - {file = "fsspec-2023.12.2.tar.gz", hash = "sha256:8548d39e8810b59c38014934f6b31e57f40c1b20f911f4cc2b85389c7e9bf0cb"}, -] - -[package.extras] -abfs = ["adlfs"] -adl = ["adlfs"] -arrow = ["pyarrow (>=1)"] -dask = ["dask", "distributed"] -devel = ["pytest", "pytest-cov"] -dropbox = ["dropbox", "dropboxdrivefs", "requests"] -full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "dask", "distributed", "dropbox", "dropboxdrivefs", "fusepy", "gcsfs", "libarchive-c", "ocifs", "panel", "paramiko", "pyarrow (>=1)", "pygit2", "requests", "s3fs", "smbprotocol", "tqdm"] -fuse = ["fusepy"] -gcs = ["gcsfs"] -git = ["pygit2"] -github = ["requests"] -gs = ["gcsfs"] -gui = ["panel"] -hdfs = ["pyarrow (>=1)"] -http = ["aiohttp (!=4.0.0a0,!=4.0.0a1)", "requests"] -libarchive = ["libarchive-c"] -oci = ["ocifs"] -s3 = ["s3fs"] -sftp = ["paramiko"] -smb = ["smbprotocol"] -ssh = ["paramiko"] -tqdm = ["tqdm"] - -[[package]] -name = "ftfy" -version = "6.3.1" -description = "Fixes mojibake and other problems with Unicode, after the fact" -optional = false -python-versions = ">=3.9" -files = [ - {file = "ftfy-6.3.1-py3-none-any.whl", hash = "sha256:7c70eb532015cd2f9adb53f101fb6c7945988d023a085d127d1573dc49dd0083"}, - {file = "ftfy-6.3.1.tar.gz", hash = "sha256:9b3c3d90f84fb267fe64d375a07b7f8912d817cf86009ae134aa03e1819506ec"}, -] - -[package.dependencies] -wcwidth = "*" - -[[package]] -name = "gevent" -version = "24.10.3" -description = "Coroutine-based network library" -optional = false -python-versions = ">=3.9" -files = [ - {file = "gevent-24.10.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:d7a1ad0f2da582f5bd238bca067e1c6c482c30c15a6e4d14aaa3215cbb2232f3"}, - {file = "gevent-24.10.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4e526fdc279c655c1e809b0c34b45844182c2a6b219802da5e411bd2cf5a8ad"}, - {file = "gevent-24.10.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57a5c4e0bdac482c5f02f240d0354e61362df73501ef6ebafce8ef635cad7527"}, - {file = "gevent-24.10.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d67daed8383326dc8b5e58d88e148d29b6b52274a489e383530b0969ae7b9cb9"}, - {file = "gevent-24.10.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e24ffea72e27987979c009536fd0868e52239b44afe6cf7135ce8aafd0f108e"}, - {file = "gevent-24.10.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:c1d80090485da1ea3d99205fe97908b31188c1f4857f08b333ffaf2de2e89d18"}, - {file = "gevent-24.10.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f0c129f81d60cda614acb4b0c5731997ca05b031fb406fcb58ad53a7ade53b13"}, - {file = "gevent-24.10.3-cp310-cp310-win_amd64.whl", hash = "sha256:26ca7a6b42d35129617025ac801135118333cad75856ffc3217b38e707383eba"}, - {file = "gevent-24.10.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:68c3a0d8402755eba7f69022e42e8021192a721ca8341908acc222ea597029b6"}, - {file = "gevent-24.10.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d850a453d66336272be4f1d3a8126777f3efdaea62d053b4829857f91e09755"}, - {file = "gevent-24.10.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8e58ee3723f1fbe07d66892f1caa7481c306f653a6829b6fd16cb23d618a5915"}, - {file = "gevent-24.10.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b52382124eca13135a3abe4f65c6bd428656975980a48e51b17aeab68bdb14db"}, - {file = "gevent-24.10.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ca2266e08f43c0e22c028801dff7d92a0b102ef20e4caeb6a46abfb95f6a328"}, - {file = "gevent-24.10.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d758f0d4dbf32502ec87bb9b536ca8055090a16f8305f0ada3ce6f34e70f2fd7"}, - {file = "gevent-24.10.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0de6eb3d55c03138fda567d9bfed28487ce5d0928c5107549767a93efdf2be26"}, - {file = "gevent-24.10.3-cp311-cp311-win_amd64.whl", hash = "sha256:385710355eadecdb70428a5ae3e7e5a45dcf888baa1426884588be9d25ac4290"}, - {file = "gevent-24.10.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3ad8fb70aa0ebc935729c9699ac31b210a49b689a7b27b7ac9f91676475f3f53"}, - {file = "gevent-24.10.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f18689f7a70d2ed0e75bad5036ec3c89690a493d4cfac8d7cdb258ac04b132bd"}, - {file = "gevent-24.10.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f4f171d4d2018170454d84c934842e1b5f6ce7468ba298f6e7f7cff15000a3"}, - {file = "gevent-24.10.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7021e26d70189b33c27173d4173f27bf4685d6b6f1c0ea50e5335f8491cb110c"}, - {file = "gevent-24.10.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34aea15f9c79f27a8faeaa361bc1e72c773a9b54a1996a2ec4eefc8bcd59a824"}, - {file = "gevent-24.10.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:8af65a4d4feaec6042c666d22c322a310fba3b47e841ad52f724b9c3ce5da48e"}, - {file = "gevent-24.10.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:89c4115e3f5ada55f92b61701a46043fe42f702b5af863b029e4c1a76f6cc2d4"}, - {file = "gevent-24.10.3-cp312-cp312-win_amd64.whl", hash = "sha256:1ce6dab94c0b0d24425ba55712de2f8c9cb21267150ca63f5bb3a0e1f165da99"}, - {file = "gevent-24.10.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f147e38423fbe96e8731f60a63475b3d2cab2f3d10578d8ee9d10c507c58a2ff"}, - {file = "gevent-24.10.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18e6984ec96fc95fd67488555c38ece3015be1f38b1bcceb27b7d6c36b343008"}, - {file = "gevent-24.10.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:051b22e2758accfddb0457728bfc9abf8c3f2ce6bca43f1ff6e07b5ed9e49bf4"}, - {file = "gevent-24.10.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb5edb6433764119a664bbb148d2aea9990950aa89cc3498f475c2408d523ea3"}, - {file = "gevent-24.10.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce417bcaaab496bc9c77f75566531e9d93816262037b8b2dbb88b0fdcd66587c"}, - {file = "gevent-24.10.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:1c3a828b033fb02b7c31da4d75014a1f82e6c072fc0523456569a57f8b025861"}, - {file = "gevent-24.10.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f2ae3efbbd120cdf4a68b7abc27a37e61e6f443c5a06ec2c6ad94c37cd8471ec"}, - {file = "gevent-24.10.3-cp313-cp313-win_amd64.whl", hash = "sha256:9e1210334a9bc9f76c3d008e0785ca62214f8a54e1325f6c2ecab3b6a572a015"}, - {file = "gevent-24.10.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70e9ed7ecb70e0df7dc97c3bc420de9a45a7c76bd5861c6cfec8c549700e681e"}, - {file = "gevent-24.10.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3ac83b74304487afa211a01909c7dd257e574db0cd429d866c298e21df7aeedf"}, - {file = "gevent-24.10.3-cp39-cp39-win32.whl", hash = "sha256:a9a89d6e396ef6f1e3968521bf56e8c4bee25b193bbf5d428b7782d582410822"}, - {file = "gevent-24.10.3-cp39-cp39-win_amd64.whl", hash = "sha256:40ea3e40e8bb4fdb143c2a8edf2ccfdebd56016c7317c341ce8094c7bee08818"}, - {file = "gevent-24.10.3-pp310-pypy310_pp73-macosx_11_0_universal2.whl", hash = "sha256:e534e6a968d74463b11de6c9c67f4b4bf61775fb00f2e6e0f7fcdd412ceade18"}, - {file = "gevent-24.10.3.tar.gz", hash = "sha256:aa7ee1bd5cabb2b7ef35105f863b386c8d5e332f754b60cfc354148bd70d35d1"}, -] - -[package.dependencies] -cffi = {version = ">=1.17.1", markers = "platform_python_implementation == \"CPython\" and sys_platform == \"win32\""} -greenlet = {version = ">=3.1.1", markers = "platform_python_implementation == \"CPython\""} -"zope.event" = "*" -"zope.interface" = "*" - -[package.extras] -dnspython = ["dnspython (>=1.16.0,<2.0)", "idna"] -docs = ["furo", "repoze.sphinx.autointerface", "sphinx", "sphinxcontrib-programoutput", "zope.schema"] -monitor = ["psutil (>=5.7.0)"] -recommended = ["cffi (>=1.17.1)", "dnspython (>=1.16.0,<2.0)", "idna", "psutil (>=5.7.0)"] -test = ["cffi (>=1.17.1)", "coverage (>=5.0)", "dnspython (>=1.16.0,<2.0)", "idna", "objgraph", "psutil (>=5.7.0)", "requests"] - -[[package]] -name = "geventhttpclient" -version = "2.3.1" -description = "HTTP client library for gevent" -optional = false -python-versions = ">=3.9" -files = [ - {file = "geventhttpclient-2.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:da22ab7bf5af4ba3d07cffee6de448b42696e53e7ac1fe97ed289037733bf1c2"}, - {file = "geventhttpclient-2.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2399e3d4e2fae8bbd91756189da6e9d84adf8f3eaace5eef0667874a705a29f8"}, - {file = "geventhttpclient-2.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d3e33e87d0d5b9f5782c4e6d3cb7e3592fea41af52713137d04776df7646d71b"}, - {file = "geventhttpclient-2.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c071db313866c3d0510feb6c0f40ec086ccf7e4a845701b6316c82c06e8b9b29"}, - {file = "geventhttpclient-2.3.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f36f0c6ef88a27e60af8369d9c2189fe372c6f2943182a7568e0f2ad33bb69f1"}, - {file = "geventhttpclient-2.3.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4624843c03a5337282a42247d987c2531193e57255ee307b36eeb4f243a0c21"}, - {file = "geventhttpclient-2.3.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d614573621ba827c417786057e1e20e9f96c4f6b3878c55b1b7b54e1026693bc"}, - {file = "geventhttpclient-2.3.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5d51330a40ac9762879d0e296c279c1beae8cfa6484bb196ac829242c416b709"}, - {file = "geventhttpclient-2.3.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bc9f2162d4e8cb86bb5322d99bfd552088a3eacd540a841298f06bb8bc1f1f03"}, - {file = "geventhttpclient-2.3.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:06e59d3397e63c65ecc7a7561a5289f0cf2e2c2252e29632741e792f57f5d124"}, - {file = "geventhttpclient-2.3.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4436eef515b3e0c1d4a453ae32e047290e780a623c1eddb11026ae9d5fb03d42"}, - {file = "geventhttpclient-2.3.1-cp310-cp310-win32.whl", hash = "sha256:5d1cf7d8a4f8e15cc8fd7d88ac4cdb058d6274203a42587e594cc9f0850ac862"}, - {file = "geventhttpclient-2.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:4deaebc121036f7ea95430c2d0f80ab085b15280e6ab677a6360b70e57020e7f"}, - {file = "geventhttpclient-2.3.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f0ae055b9ce1704f2ce72c0847df28f4e14dbb3eea79256cda6c909d82688ea3"}, - {file = "geventhttpclient-2.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f087af2ac439495b5388841d6f3c4de8d2573ca9870593d78f7b554aa5cfa7f5"}, - {file = "geventhttpclient-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:76c367d175810facfe56281e516c9a5a4a191eff76641faaa30aa33882ed4b2f"}, - {file = "geventhttpclient-2.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a58376d0d461fe0322ff2ad362553b437daee1eeb92b4c0e3b1ffef9e77defbe"}, - {file = "geventhttpclient-2.3.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f440cc704f8a9869848a109b2c401805c17c070539b2014e7b884ecfc8591e33"}, - {file = "geventhttpclient-2.3.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f10c62994f9052f23948c19de930b2d1f063240462c8bd7077c2b3290e61f4fa"}, - {file = "geventhttpclient-2.3.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52c45d9f3dd9627844c12e9ca347258c7be585bed54046336220e25ea6eac155"}, - {file = "geventhttpclient-2.3.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:77c1a2c6e3854bf87cd5588b95174640c8a881716bd07fa0d131d082270a6795"}, - {file = "geventhttpclient-2.3.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:ce649d4e25c2d56023471df0bf1e8e2ab67dfe4ff12ce3e8fe7e6fae30cd672a"}, - {file = "geventhttpclient-2.3.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:265d9f31b4ac8f688eebef0bd4c814ffb37a16f769ad0c8c8b8c24a84db8eab5"}, - {file = "geventhttpclient-2.3.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2de436a9d61dae877e4e811fb3e2594e2a1df1b18f4280878f318aef48a562b9"}, - {file = "geventhttpclient-2.3.1-cp311-cp311-win32.whl", hash = "sha256:83e22178b9480b0a95edf0053d4f30b717d0b696b3c262beabe6964d9c5224b1"}, - {file = "geventhttpclient-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:97b072a282233384c1302a7dee88ad8bfedc916f06b1bc1da54f84980f1406a9"}, - {file = "geventhttpclient-2.3.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:e1c90abcc2735cd8dd2d2572a13da32f6625392dc04862decb5c6476a3ddee22"}, - {file = "geventhttpclient-2.3.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5deb41c2f51247b4e568c14964f59d7b8e537eff51900564c88af3200004e678"}, - {file = "geventhttpclient-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c6f1a56a66a90c4beae2f009b5e9d42db9a58ced165aa35441ace04d69cb7b37"}, - {file = "geventhttpclient-2.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ee6e741849c29e3129b1ec3828ac3a5e5dcb043402f852ea92c52334fb8cabf"}, - {file = "geventhttpclient-2.3.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d0972096a63b1ddaa73fa3dab2c7a136e3ab8bf7999a2f85a5dee851fa77cdd"}, - {file = "geventhttpclient-2.3.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00675ba682fb7d19d659c14686fa8a52a65e3f301b56c2a4ee6333b380dd9467"}, - {file = "geventhttpclient-2.3.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea77b67c186df90473416f4403839728f70ef6cf1689cec97b4f6bbde392a8a8"}, - {file = "geventhttpclient-2.3.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ddcc3f0fdffd9a3801e1005b73026202cffed8199863fdef9315bea9a860a032"}, - {file = "geventhttpclient-2.3.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:c9f1ef4ec048563cc621a47ff01a4f10048ff8b676d7a4d75e5433ed8e703e56"}, - {file = "geventhttpclient-2.3.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:a364b30bec7a0a00dbe256e2b6807e4dc866bead7ac84aaa51ca5e2c3d15c258"}, - {file = "geventhttpclient-2.3.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:25d255383d3d6a6fbd643bb51ae1a7e4f6f7b0dbd5f3225b537d0bd0432eaf39"}, - {file = "geventhttpclient-2.3.1-cp312-cp312-win32.whl", hash = "sha256:ad0b507e354d2f398186dcb12fe526d0594e7c9387b514fb843f7a14fdf1729a"}, - {file = "geventhttpclient-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:7924e0883bc2b177cfe27aa65af6bb9dd57f3e26905c7675a2d1f3ef69df7cca"}, - {file = "geventhttpclient-2.3.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:fe912c6456faab196b952adcd63e9353a0d5c8deb31c8d733d38f4f0ab22e359"}, - {file = "geventhttpclient-2.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8b599359779c2278018786c35d70664d441a7cd0d6baef2b2cd0d1685cf478ed"}, - {file = "geventhttpclient-2.3.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:34107b506e2c40ec7784efa282469bf86888cacddced463dceeb58c201834897"}, - {file = "geventhttpclient-2.3.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc34031905b2b31a80d88cd33d7e42b81812950e5304860ab6a65ee2803e2046"}, - {file = "geventhttpclient-2.3.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50b54f67ba2087f4d9d2172065c5c5de0f0c7f865ac350116e5452de4be31444"}, - {file = "geventhttpclient-2.3.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9ddeb431836c2ef7fd33c505a06180dc907b474e0e8537a43ff12e12c9bf0307"}, - {file = "geventhttpclient-2.3.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4890713433ca19b081f70b5f7ad258a0979ec3354f9538b50b3ad7d0a86f88de"}, - {file = "geventhttpclient-2.3.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b8ca7dcbe94cb563341087b00b6fbd0fdd70b2acc1b5d963f9ebbfbc1e5e2893"}, - {file = "geventhttpclient-2.3.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:05a1bbdd43ae36bcc10b3dbfa0806aefc5033a91efecfddfe56159446a46ea71"}, - {file = "geventhttpclient-2.3.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:f82c454595a88a5e510ae0985711ef398386998b6f37d90fc30e9ff1a2001280"}, - {file = "geventhttpclient-2.3.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6b032a5cdb1721921f4cd36aad620af318263b462962cfb23d648cdb93aab232"}, - {file = "geventhttpclient-2.3.1-cp39-cp39-win32.whl", hash = "sha256:ce2c7d18bac7ffdacc4a86cd490bea6136a7d1e1170f8624f2e3bbe3b189d5b8"}, - {file = "geventhttpclient-2.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:6ca50dd9761971d3557b897108933b34fb4a11533d52f0f2753840c740a2861a"}, - {file = "geventhttpclient-2.3.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:c31431e38df45b3c79bf3c9427c796adb8263d622bc6fa25e2f6ba916c2aad93"}, - {file = "geventhttpclient-2.3.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:855ab1e145575769b180b57accb0573a77cd6a7392f40a6ef7bc9a4926ebd77b"}, - {file = "geventhttpclient-2.3.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a374aad77c01539e786d0c7829bec2eba034ccd45733c1bf9811ad18d2a8ecd"}, - {file = "geventhttpclient-2.3.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66c1e97460608304f400485ac099736fff3566d3d8db2038533d466f8cf5de5a"}, - {file = "geventhttpclient-2.3.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:4f843f81ee44ba4c553a1b3f73115e0ad8f00044023c24db29f5b1df3da08465"}, - {file = "geventhttpclient-2.3.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:321b73c73d73b85cfeff36b9b5ee04174ec8406fb3dadc129558a26ccb879360"}, - {file = "geventhttpclient-2.3.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:829d03c2a140edbe74ad1fb4f850384f585f3e06fc47cfe647d065412b93926f"}, - {file = "geventhttpclient-2.3.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:994c543f156db7bce3bae15491a0e041eeb3f1cf467e0d1db0c161a900a90bec"}, - {file = "geventhttpclient-2.3.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4beff505306aa9da5cdfe2f206b403ec7c8d06a22d6b7248365772858c4ee8c"}, - {file = "geventhttpclient-2.3.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:fb0a9673074541ccda09a2423fa16f4528819ceb1ba19d252213f6aca7d4b44a"}, - {file = "geventhttpclient-2.3.1.tar.gz", hash = "sha256:b40ddac8517c456818942c7812f555f84702105c82783238c9fcb8dc12675185"}, -] - -[package.dependencies] -brotli = "*" -certifi = "*" -gevent = "*" -urllib3 = "*" - -[package.extras] -benchmarks = ["httplib2", "httpx", "requests", "urllib3"] -dev = ["dpkt", "pytest", "requests"] -examples = ["oauth2"] - -[[package]] -name = "greenlet" -version = "3.1.1" -description = "Lightweight in-process concurrent programming" -optional = false -python-versions = ">=3.7" -files = [ - {file = "greenlet-3.1.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:0bbae94a29c9e5c7e4a2b7f0aae5c17e8e90acbfd3bf6270eeba60c39fce3563"}, - {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fde093fb93f35ca72a556cf72c92ea3ebfda3d79fc35bb19fbe685853869a83"}, - {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36b89d13c49216cadb828db8dfa6ce86bbbc476a82d3a6c397f0efae0525bdd0"}, - {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b6150a85e1b33b40b1464a3f9988dcc5251d6ed06842abff82e42632fac120"}, - {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93147c513fac16385d1036b7e5b102c7fbbdb163d556b791f0f11eada7ba65dc"}, - {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da7a9bff22ce038e19bf62c4dd1ec8391062878710ded0a845bcf47cc0200617"}, - {file = "greenlet-3.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b2795058c23988728eec1f36a4e5e4ebad22f8320c85f3587b539b9ac84128d7"}, - {file = "greenlet-3.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ed10eac5830befbdd0c32f83e8aa6288361597550ba669b04c48f0f9a2c843c6"}, - {file = "greenlet-3.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:77c386de38a60d1dfb8e55b8c1101d68c79dfdd25c7095d51fec2dd800892b80"}, - {file = "greenlet-3.1.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e4d333e558953648ca09d64f13e6d8f0523fa705f51cae3f03b5983489958c70"}, - {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fc016b73c94e98e29af67ab7b9a879c307c6731a2c9da0db5a7d9b7edd1159"}, - {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5e975ca70269d66d17dd995dafc06f1b06e8cb1ec1e9ed54c1d1e4a7c4cf26e"}, - {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b2813dc3de8c1ee3f924e4d4227999285fd335d1bcc0d2be6dc3f1f6a318ec1"}, - {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e347b3bfcf985a05e8c0b7d462ba6f15b1ee1c909e2dcad795e49e91b152c383"}, - {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e8f8c9cb53cdac7ba9793c276acd90168f416b9ce36799b9b885790f8ad6c0a"}, - {file = "greenlet-3.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:62ee94988d6b4722ce0028644418d93a52429e977d742ca2ccbe1c4f4a792511"}, - {file = "greenlet-3.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1776fd7f989fc6b8d8c8cb8da1f6b82c5814957264d1f6cf818d475ec2bf6395"}, - {file = "greenlet-3.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:48ca08c771c268a768087b408658e216133aecd835c0ded47ce955381105ba39"}, - {file = "greenlet-3.1.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9"}, - {file = "greenlet-3.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0"}, - {file = "greenlet-3.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942"}, - {file = "greenlet-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01"}, - {file = "greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1"}, - {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff"}, - {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a"}, - {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e"}, - {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4"}, - {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e"}, - {file = "greenlet-3.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1"}, - {file = "greenlet-3.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c"}, - {file = "greenlet-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761"}, - {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011"}, - {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13"}, - {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475"}, - {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b"}, - {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822"}, - {file = "greenlet-3.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01"}, - {file = "greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6"}, - {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47da355d8687fd65240c364c90a31569a133b7b60de111c255ef5b606f2ae291"}, - {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:98884ecf2ffb7d7fe6bd517e8eb99d31ff7855a840fa6d0d63cd07c037f6a981"}, - {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1d4aeb8891338e60d1ab6127af1fe45def5259def8094b9c7e34690c8858803"}, - {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db32b5348615a04b82240cc67983cb315309e88d444a288934ee6ceaebcad6cc"}, - {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dcc62f31eae24de7f8dce72134c8651c58000d3b1868e01392baea7c32c247de"}, - {file = "greenlet-3.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1d3755bcb2e02de341c55b4fca7a745a24a9e7212ac953f6b3a48d117d7257aa"}, - {file = "greenlet-3.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:b8da394b34370874b4572676f36acabac172602abf054cbc4ac910219f3340af"}, - {file = "greenlet-3.1.1-cp37-cp37m-win32.whl", hash = "sha256:a0dfc6c143b519113354e780a50381508139b07d2177cb6ad6a08278ec655798"}, - {file = "greenlet-3.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:54558ea205654b50c438029505def3834e80f0869a70fb15b871c29b4575ddef"}, - {file = "greenlet-3.1.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:346bed03fe47414091be4ad44786d1bd8bef0c3fcad6ed3dee074a032ab408a9"}, - {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfc59d69fc48664bc693842bd57acfdd490acafda1ab52c7836e3fc75c90a111"}, - {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d21e10da6ec19b457b82636209cbe2331ff4306b54d06fa04b7c138ba18c8a81"}, - {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:37b9de5a96111fc15418819ab4c4432e4f3c2ede61e660b1e33971eba26ef9ba"}, - {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ef9ea3f137e5711f0dbe5f9263e8c009b7069d8a1acea822bd5e9dae0ae49c8"}, - {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85f3ff71e2e60bd4b4932a043fbbe0f499e263c628390b285cb599154a3b03b1"}, - {file = "greenlet-3.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:95ffcf719966dd7c453f908e208e14cde192e09fde6c7186c8f1896ef778d8cd"}, - {file = "greenlet-3.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:03a088b9de532cbfe2ba2034b2b85e82df37874681e8c470d6fb2f8c04d7e4b7"}, - {file = "greenlet-3.1.1-cp38-cp38-win32.whl", hash = "sha256:8b8b36671f10ba80e159378df9c4f15c14098c4fd73a36b9ad715f057272fbef"}, - {file = "greenlet-3.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:7017b2be767b9d43cc31416aba48aab0d2309ee31b4dbf10a1d38fb7972bdf9d"}, - {file = "greenlet-3.1.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:396979749bd95f018296af156201d6211240e7a23090f50a8d5d18c370084dc3"}, - {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca9d0ff5ad43e785350894d97e13633a66e2b50000e8a183a50a88d834752d42"}, - {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f6ff3b14f2df4c41660a7dec01045a045653998784bf8cfcb5a525bdffffbc8f"}, - {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94ebba31df2aa506d7b14866fed00ac141a867e63143fe5bca82a8e503b36437"}, - {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73aaad12ac0ff500f62cebed98d8789198ea0e6f233421059fa68a5aa7220145"}, - {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63e4844797b975b9af3a3fb8f7866ff08775f5426925e1e0bbcfe7932059a12c"}, - {file = "greenlet-3.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7939aa3ca7d2a1593596e7ac6d59391ff30281ef280d8632fa03d81f7c5f955e"}, - {file = "greenlet-3.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d0028e725ee18175c6e422797c407874da24381ce0690d6b9396c204c7f7276e"}, - {file = "greenlet-3.1.1-cp39-cp39-win32.whl", hash = "sha256:5e06afd14cbaf9e00899fae69b24a32f2196c19de08fcb9f4779dd4f004e5e7c"}, - {file = "greenlet-3.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:3319aa75e0e0639bc15ff54ca327e8dc7a6fe404003496e3c6925cd3142e0e22"}, - {file = "greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467"}, -] - -[package.extras] -docs = ["Sphinx", "furo"] -test = ["objgraph", "psutil"] - -[[package]] -name = "gunicorn" -version = "23.0.0" -description = "WSGI HTTP Server for UNIX" -optional = false -python-versions = ">=3.7" -files = [ - {file = "gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d"}, - {file = "gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec"}, -] - -[package.dependencies] -packaging = "*" - -[package.extras] -eventlet = ["eventlet (>=0.24.1,!=0.36.0)"] -gevent = ["gevent (>=1.4.0)"] -setproctitle = ["setproctitle"] -testing = ["coverage", "eventlet", "gevent", "pytest", "pytest-cov"] -tornado = ["tornado (>=0.2)"] - -[[package]] -name = "h11" -version = "0.14.0" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -optional = false -python-versions = ">=3.7" -files = [ - {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, - {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, -] - -[[package]] -name = "httpcore" -version = "1.0.2" -description = "A minimal low-level HTTP client." -optional = false -python-versions = ">=3.8" -files = [ - {file = "httpcore-1.0.2-py3-none-any.whl", hash = "sha256:096cc05bca73b8e459a1fc3dcf585148f63e534eae4339559c9b8a8d6399acc7"}, - {file = "httpcore-1.0.2.tar.gz", hash = "sha256:9fc092e4799b26174648e54b74ed5f683132a464e95643b226e00c2ed2fa6535"}, -] - -[package.dependencies] -certifi = "*" -h11 = ">=0.13,<0.15" - -[package.extras] -asyncio = ["anyio (>=4.0,<5.0)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] -trio = ["trio (>=0.22.0,<0.23.0)"] - -[[package]] -name = "httptools" -version = "0.6.4" -description = "A collection of framework independent HTTP protocol utils." -optional = false -python-versions = ">=3.8.0" -files = [ - {file = "httptools-0.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3c73ce323711a6ffb0d247dcd5a550b8babf0f757e86a52558fe5b86d6fefcc0"}, - {file = "httptools-0.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:345c288418f0944a6fe67be8e6afa9262b18c7626c3ef3c28adc5eabc06a68da"}, - {file = "httptools-0.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deee0e3343f98ee8047e9f4c5bc7cedbf69f5734454a94c38ee829fb2d5fa3c1"}, - {file = "httptools-0.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca80b7485c76f768a3bc83ea58373f8db7b015551117375e4918e2aa77ea9b50"}, - {file = "httptools-0.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:90d96a385fa941283ebd231464045187a31ad932ebfa541be8edf5b3c2328959"}, - {file = "httptools-0.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:59e724f8b332319e2875efd360e61ac07f33b492889284a3e05e6d13746876f4"}, - {file = "httptools-0.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:c26f313951f6e26147833fc923f78f95604bbec812a43e5ee37f26dc9e5a686c"}, - {file = "httptools-0.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f47f8ed67cc0ff862b84a1189831d1d33c963fb3ce1ee0c65d3b0cbe7b711069"}, - {file = "httptools-0.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0614154d5454c21b6410fdf5262b4a3ddb0f53f1e1721cfd59d55f32138c578a"}, - {file = "httptools-0.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8787367fbdfccae38e35abf7641dafc5310310a5987b689f4c32cc8cc3ee975"}, - {file = "httptools-0.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40b0f7fe4fd38e6a507bdb751db0379df1e99120c65fbdc8ee6c1d044897a636"}, - {file = "httptools-0.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40a5ec98d3f49904b9fe36827dcf1aadfef3b89e2bd05b0e35e94f97c2b14721"}, - {file = "httptools-0.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dacdd3d10ea1b4ca9df97a0a303cbacafc04b5cd375fa98732678151643d4988"}, - {file = "httptools-0.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:288cd628406cc53f9a541cfaf06041b4c71d751856bab45e3702191f931ccd17"}, - {file = "httptools-0.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df017d6c780287d5c80601dafa31f17bddb170232d85c066604d8558683711a2"}, - {file = "httptools-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85071a1e8c2d051b507161f6c3e26155b5c790e4e28d7f236422dbacc2a9cc44"}, - {file = "httptools-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69422b7f458c5af875922cdb5bd586cc1f1033295aa9ff63ee196a87519ac8e1"}, - {file = "httptools-0.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16e603a3bff50db08cd578d54f07032ca1631450ceb972c2f834c2b860c28ea2"}, - {file = "httptools-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec4f178901fa1834d4a060320d2f3abc5c9e39766953d038f1458cb885f47e81"}, - {file = "httptools-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb89ecf8b290f2e293325c646a211ff1c2493222798bb80a530c5e7502494f"}, - {file = "httptools-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:db78cb9ca56b59b016e64b6031eda5653be0589dba2b1b43453f6e8b405a0970"}, - {file = "httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660"}, - {file = "httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083"}, - {file = "httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3"}, - {file = "httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071"}, - {file = "httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5"}, - {file = "httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0"}, - {file = "httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8"}, - {file = "httptools-0.6.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:d3f0d369e7ffbe59c4b6116a44d6a8eb4783aae027f2c0b366cf0aa964185dba"}, - {file = "httptools-0.6.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:94978a49b8f4569ad607cd4946b759d90b285e39c0d4640c6b36ca7a3ddf2efc"}, - {file = "httptools-0.6.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40dc6a8e399e15ea525305a2ddba998b0af5caa2566bcd79dcbe8948181eeaff"}, - {file = "httptools-0.6.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab9ba8dcf59de5181f6be44a77458e45a578fc99c31510b8c65b7d5acc3cf490"}, - {file = "httptools-0.6.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:fc411e1c0a7dcd2f902c7c48cf079947a7e65b5485dea9decb82b9105ca71a43"}, - {file = "httptools-0.6.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:d54efd20338ac52ba31e7da78e4a72570cf729fac82bc31ff9199bedf1dc7440"}, - {file = "httptools-0.6.4-cp38-cp38-win_amd64.whl", hash = "sha256:df959752a0c2748a65ab5387d08287abf6779ae9165916fe053e68ae1fbdc47f"}, - {file = "httptools-0.6.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:85797e37e8eeaa5439d33e556662cc370e474445d5fab24dcadc65a8ffb04003"}, - {file = "httptools-0.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:db353d22843cf1028f43c3651581e4bb49374d85692a85f95f7b9a130e1b2cab"}, - {file = "httptools-0.6.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1ffd262a73d7c28424252381a5b854c19d9de5f56f075445d33919a637e3547"}, - {file = "httptools-0.6.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:703c346571fa50d2e9856a37d7cd9435a25e7fd15e236c397bf224afaa355fe9"}, - {file = "httptools-0.6.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aafe0f1918ed07b67c1e838f950b1c1fabc683030477e60b335649b8020e1076"}, - {file = "httptools-0.6.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0e563e54979e97b6d13f1bbc05a96109923e76b901f786a5eae36e99c01237bd"}, - {file = "httptools-0.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:b799de31416ecc589ad79dd85a0b2657a8fe39327944998dea368c1d4c9e55e6"}, - {file = "httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c"}, -] - -[package.extras] -test = ["Cython (>=0.29.24)"] - -[[package]] -name = "httpx" -version = "0.28.1" -description = "The next generation HTTP client." -optional = false -python-versions = ">=3.8" -files = [ - {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, - {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, -] - -[package.dependencies] -anyio = "*" -certifi = "*" -httpcore = "==1.*" -idna = "*" - -[package.extras] -brotli = ["brotli", "brotlicffi"] -cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "huggingface-hub" -version = "0.27.1" -description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" -optional = false -python-versions = ">=3.8.0" -files = [ - {file = "huggingface_hub-0.27.1-py3-none-any.whl", hash = "sha256:1c5155ca7d60b60c2e2fc38cbb3ffb7f7c3adf48f824015b219af9061771daec"}, - {file = "huggingface_hub-0.27.1.tar.gz", hash = "sha256:c004463ca870283909d715d20f066ebd6968c2207dae9393fdffb3c1d4d8f98b"}, -] - -[package.dependencies] -filelock = "*" -fsspec = ">=2023.5.0" -packaging = ">=20.9" -pyyaml = ">=5.1" -requests = "*" -tqdm = ">=4.42.1" -typing-extensions = ">=3.7.4.3" - -[package.extras] -all = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "fastapi", "gradio (>=4.0.0)", "jedi", "libcst (==1.4.0)", "mypy (==1.5.1)", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.5.0)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] -cli = ["InquirerPy (==0.3.4)"] -dev = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "fastapi", "gradio (>=4.0.0)", "jedi", "libcst (==1.4.0)", "mypy (==1.5.1)", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.5.0)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] -fastai = ["fastai (>=2.4)", "fastcore (>=1.3.27)", "toml"] -hf-transfer = ["hf-transfer (>=0.1.4)"] -inference = ["aiohttp"] -quality = ["libcst (==1.4.0)", "mypy (==1.5.1)", "ruff (>=0.5.0)"] -tensorflow = ["graphviz", "pydot", "tensorflow"] -tensorflow-testing = ["keras (<3.0)", "tensorflow"] -testing = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "fastapi", "gradio (>=4.0.0)", "jedi", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "soundfile", "urllib3 (<2.0)"] -torch = ["safetensors[torch]", "torch"] -typing = ["types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)"] - -[[package]] -name = "humanfriendly" -version = "10.0" -description = "Human friendly output for text interfaces using Python" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -files = [ - {file = "humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477"}, - {file = "humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"}, -] - -[package.dependencies] -pyreadline3 = {version = "*", markers = "sys_platform == \"win32\" and python_version >= \"3.8\""} - -[[package]] -name = "idna" -version = "3.6" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.5" -files = [ - {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, - {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, -] - -[[package]] -name = "imageio" -version = "2.33.1" -description = "Library for reading and writing a wide range of image, video, scientific, and volumetric data formats." -optional = false -python-versions = ">=3.8" -files = [ - {file = "imageio-2.33.1-py3-none-any.whl", hash = "sha256:c5094c48ccf6b2e6da8b4061cd95e1209380afafcbeae4a4e280938cce227e1d"}, - {file = "imageio-2.33.1.tar.gz", hash = "sha256:78722d40b137bd98f5ec7312119f8aea9ad2049f76f434748eb306b6937cc1ce"}, -] - -[package.dependencies] -numpy = "*" -pillow = ">=8.3.2" - -[package.extras] -all-plugins = ["astropy", "av", "imageio-ffmpeg", "pillow-heif", "psutil", "tifffile"] -all-plugins-pypy = ["av", "imageio-ffmpeg", "pillow-heif", "psutil", "tifffile"] -build = ["wheel"] -dev = ["black", "flake8", "fsspec[github]", "pytest", "pytest-cov"] -docs = ["numpydoc", "pydata-sphinx-theme", "sphinx (<6)"] -ffmpeg = ["imageio-ffmpeg", "psutil"] -fits = ["astropy"] -full = ["astropy", "av", "black", "flake8", "fsspec[github]", "gdal", "imageio-ffmpeg", "itk", "numpydoc", "pillow-heif", "psutil", "pydata-sphinx-theme", "pytest", "pytest-cov", "sphinx (<6)", "tifffile", "wheel"] -gdal = ["gdal"] -itk = ["itk"] -linting = ["black", "flake8"] -pillow-heif = ["pillow-heif"] -pyav = ["av"] -test = ["fsspec[github]", "pytest", "pytest-cov"] -tifffile = ["tifffile"] - -[[package]] -name = "iniconfig" -version = "2.0.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.7" -files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, -] - -[[package]] -name = "insightface" -version = "0.7.3" -description = "InsightFace Python Library" -optional = false -python-versions = "*" -files = [ - {file = "insightface-0.7.3.tar.gz", hash = "sha256:f191f719612ebb37018f41936814500544cd0f86e6fcd676c023f354c668ddf7"}, -] - -[package.dependencies] -albumentations = "*" -cython = "*" -easydict = "*" -matplotlib = "*" -numpy = "*" -onnx = "*" -Pillow = "*" -prettytable = "*" -requests = "*" -scikit-image = "*" -scikit-learn = "*" -scipy = "*" -tqdm = "*" - -[[package]] -name = "itsdangerous" -version = "2.1.2" -description = "Safely pass data to untrusted environments and back." -optional = false -python-versions = ">=3.7" -files = [ - {file = "itsdangerous-2.1.2-py3-none-any.whl", hash = "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44"}, - {file = "itsdangerous-2.1.2.tar.gz", hash = "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a"}, -] - -[[package]] -name = "jinja2" -version = "3.1.4" -description = "A very fast and expressive template engine." -optional = false -python-versions = ">=3.7" -files = [ - {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, - {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, -] - -[package.dependencies] -MarkupSafe = ">=2.0" - -[package.extras] -i18n = ["Babel (>=2.7)"] - -[[package]] -name = "joblib" -version = "1.3.2" -description = "Lightweight pipelining with Python functions" -optional = false -python-versions = ">=3.7" -files = [ - {file = "joblib-1.3.2-py3-none-any.whl", hash = "sha256:ef4331c65f239985f3f2220ecc87db222f08fd22097a3dd5698f693875f8cbb9"}, - {file = "joblib-1.3.2.tar.gz", hash = "sha256:92f865e621e17784e7955080b6d042489e3b8e294949cc44c6eac304f59772b1"}, -] - -[[package]] -name = "kiwisolver" -version = "1.4.5" -description = "A fast implementation of the Cassowary constraint solver" -optional = false -python-versions = ">=3.7" -files = [ - {file = "kiwisolver-1.4.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:05703cf211d585109fcd72207a31bb170a0f22144d68298dc5e61b3c946518af"}, - {file = "kiwisolver-1.4.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:146d14bebb7f1dc4d5fbf74f8a6cb15ac42baadee8912eb84ac0b3b2a3dc6ac3"}, - {file = "kiwisolver-1.4.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6ef7afcd2d281494c0a9101d5c571970708ad911d028137cd558f02b851c08b4"}, - {file = "kiwisolver-1.4.5-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9eaa8b117dc8337728e834b9c6e2611f10c79e38f65157c4c38e9400286f5cb1"}, - {file = "kiwisolver-1.4.5-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ec20916e7b4cbfb1f12380e46486ec4bcbaa91a9c448b97023fde0d5bbf9e4ff"}, - {file = "kiwisolver-1.4.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:39b42c68602539407884cf70d6a480a469b93b81b7701378ba5e2328660c847a"}, - {file = "kiwisolver-1.4.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aa12042de0171fad672b6c59df69106d20d5596e4f87b5e8f76df757a7c399aa"}, - {file = "kiwisolver-1.4.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a40773c71d7ccdd3798f6489aaac9eee213d566850a9533f8d26332d626b82c"}, - {file = "kiwisolver-1.4.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:19df6e621f6d8b4b9c4d45f40a66839294ff2bb235e64d2178f7522d9170ac5b"}, - {file = "kiwisolver-1.4.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:83d78376d0d4fd884e2c114d0621624b73d2aba4e2788182d286309ebdeed770"}, - {file = "kiwisolver-1.4.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e391b1f0a8a5a10ab3b9bb6afcfd74f2175f24f8975fb87ecae700d1503cdee0"}, - {file = "kiwisolver-1.4.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:852542f9481f4a62dbb5dd99e8ab7aedfeb8fb6342349a181d4036877410f525"}, - {file = "kiwisolver-1.4.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59edc41b24031bc25108e210c0def6f6c2191210492a972d585a06ff246bb79b"}, - {file = "kiwisolver-1.4.5-cp310-cp310-win32.whl", hash = "sha256:a6aa6315319a052b4ee378aa171959c898a6183f15c1e541821c5c59beaa0238"}, - {file = "kiwisolver-1.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:d0ef46024e6a3d79c01ff13801cb19d0cad7fd859b15037aec74315540acc276"}, - {file = "kiwisolver-1.4.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:11863aa14a51fd6ec28688d76f1735f8f69ab1fabf388851a595d0721af042f5"}, - {file = "kiwisolver-1.4.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8ab3919a9997ab7ef2fbbed0cc99bb28d3c13e6d4b1ad36e97e482558a91be90"}, - {file = "kiwisolver-1.4.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fcc700eadbbccbf6bc1bcb9dbe0786b4b1cb91ca0dcda336eef5c2beed37b797"}, - {file = "kiwisolver-1.4.5-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dfdd7c0b105af050eb3d64997809dc21da247cf44e63dc73ff0fd20b96be55a9"}, - {file = "kiwisolver-1.4.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76c6a5964640638cdeaa0c359382e5703e9293030fe730018ca06bc2010c4437"}, - {file = "kiwisolver-1.4.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bbea0db94288e29afcc4c28afbf3a7ccaf2d7e027489c449cf7e8f83c6346eb9"}, - {file = "kiwisolver-1.4.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ceec1a6bc6cab1d6ff5d06592a91a692f90ec7505d6463a88a52cc0eb58545da"}, - {file = "kiwisolver-1.4.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:040c1aebeda72197ef477a906782b5ab0d387642e93bda547336b8957c61022e"}, - {file = "kiwisolver-1.4.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f91de7223d4c7b793867797bacd1ee53bfe7359bd70d27b7b58a04efbb9436c8"}, - {file = "kiwisolver-1.4.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:faae4860798c31530dd184046a900e652c95513796ef51a12bc086710c2eec4d"}, - {file = "kiwisolver-1.4.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:b0157420efcb803e71d1b28e2c287518b8808b7cf1ab8af36718fd0a2c453eb0"}, - {file = "kiwisolver-1.4.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:06f54715b7737c2fecdbf140d1afb11a33d59508a47bf11bb38ecf21dc9ab79f"}, - {file = "kiwisolver-1.4.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fdb7adb641a0d13bdcd4ef48e062363d8a9ad4a182ac7647ec88f695e719ae9f"}, - {file = "kiwisolver-1.4.5-cp311-cp311-win32.whl", hash = "sha256:bb86433b1cfe686da83ce32a9d3a8dd308e85c76b60896d58f082136f10bffac"}, - {file = "kiwisolver-1.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:6c08e1312a9cf1074d17b17728d3dfce2a5125b2d791527f33ffbe805200a355"}, - {file = "kiwisolver-1.4.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:32d5cf40c4f7c7b3ca500f8985eb3fb3a7dfc023215e876f207956b5ea26632a"}, - {file = "kiwisolver-1.4.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f846c260f483d1fd217fe5ed7c173fb109efa6b1fc8381c8b7552c5781756192"}, - {file = "kiwisolver-1.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5ff5cf3571589b6d13bfbfd6bcd7a3f659e42f96b5fd1c4830c4cf21d4f5ef45"}, - {file = "kiwisolver-1.4.5-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7269d9e5f1084a653d575c7ec012ff57f0c042258bf5db0954bf551c158466e7"}, - {file = "kiwisolver-1.4.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da802a19d6e15dffe4b0c24b38b3af68e6c1a68e6e1d8f30148c83864f3881db"}, - {file = "kiwisolver-1.4.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3aba7311af82e335dd1e36ffff68aaca609ca6290c2cb6d821a39aa075d8e3ff"}, - {file = "kiwisolver-1.4.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:763773d53f07244148ccac5b084da5adb90bfaee39c197554f01b286cf869228"}, - {file = "kiwisolver-1.4.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2270953c0d8cdab5d422bee7d2007f043473f9d2999631c86a223c9db56cbd16"}, - {file = "kiwisolver-1.4.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d099e745a512f7e3bbe7249ca835f4d357c586d78d79ae8f1dcd4d8adeb9bda9"}, - {file = "kiwisolver-1.4.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:74db36e14a7d1ce0986fa104f7d5637aea5c82ca6326ed0ec5694280942d1162"}, - {file = "kiwisolver-1.4.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:7e5bab140c309cb3a6ce373a9e71eb7e4873c70c2dda01df6820474f9889d6d4"}, - {file = "kiwisolver-1.4.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:0f114aa76dc1b8f636d077979c0ac22e7cd8f3493abbab152f20eb8d3cda71f3"}, - {file = "kiwisolver-1.4.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:88a2df29d4724b9237fc0c6eaf2a1adae0cdc0b3e9f4d8e7dc54b16812d2d81a"}, - {file = "kiwisolver-1.4.5-cp312-cp312-win32.whl", hash = "sha256:72d40b33e834371fd330fb1472ca19d9b8327acb79a5821d4008391db8e29f20"}, - {file = "kiwisolver-1.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:2c5674c4e74d939b9d91dda0fae10597ac7521768fec9e399c70a1f27e2ea2d9"}, - {file = "kiwisolver-1.4.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3a2b053a0ab7a3960c98725cfb0bf5b48ba82f64ec95fe06f1d06c99b552e130"}, - {file = "kiwisolver-1.4.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cd32d6c13807e5c66a7cbb79f90b553642f296ae4518a60d8d76243b0ad2898"}, - {file = "kiwisolver-1.4.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59ec7b7c7e1a61061850d53aaf8e93db63dce0c936db1fda2658b70e4a1be709"}, - {file = "kiwisolver-1.4.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:da4cfb373035def307905d05041c1d06d8936452fe89d464743ae7fb8371078b"}, - {file = "kiwisolver-1.4.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2400873bccc260b6ae184b2b8a4fec0e4082d30648eadb7c3d9a13405d861e89"}, - {file = "kiwisolver-1.4.5-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:1b04139c4236a0f3aff534479b58f6f849a8b351e1314826c2d230849ed48985"}, - {file = "kiwisolver-1.4.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:4e66e81a5779b65ac21764c295087de82235597a2293d18d943f8e9e32746265"}, - {file = "kiwisolver-1.4.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:7931d8f1f67c4be9ba1dd9c451fb0eeca1a25b89e4d3f89e828fe12a519b782a"}, - {file = "kiwisolver-1.4.5-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:b3f7e75f3015df442238cca659f8baa5f42ce2a8582727981cbfa15fee0ee205"}, - {file = "kiwisolver-1.4.5-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:bbf1d63eef84b2e8c89011b7f2235b1e0bf7dacc11cac9431fc6468e99ac77fb"}, - {file = "kiwisolver-1.4.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:4c380469bd3f970ef677bf2bcba2b6b0b4d5c75e7a020fb863ef75084efad66f"}, - {file = "kiwisolver-1.4.5-cp37-cp37m-win32.whl", hash = "sha256:9408acf3270c4b6baad483865191e3e582b638b1654a007c62e3efe96f09a9a3"}, - {file = "kiwisolver-1.4.5-cp37-cp37m-win_amd64.whl", hash = "sha256:5b94529f9b2591b7af5f3e0e730a4e0a41ea174af35a4fd067775f9bdfeee01a"}, - {file = "kiwisolver-1.4.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:11c7de8f692fc99816e8ac50d1d1aef4f75126eefc33ac79aac02c099fd3db71"}, - {file = "kiwisolver-1.4.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:53abb58632235cd154176ced1ae8f0d29a6657aa1aa9decf50b899b755bc2b93"}, - {file = "kiwisolver-1.4.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:88b9f257ca61b838b6f8094a62418421f87ac2a1069f7e896c36a7d86b5d4c29"}, - {file = "kiwisolver-1.4.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3195782b26fc03aa9c6913d5bad5aeb864bdc372924c093b0f1cebad603dd712"}, - {file = "kiwisolver-1.4.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc579bf0f502e54926519451b920e875f433aceb4624a3646b3252b5caa9e0b6"}, - {file = "kiwisolver-1.4.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a580c91d686376f0f7c295357595c5a026e6cbc3d77b7c36e290201e7c11ecb"}, - {file = "kiwisolver-1.4.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cfe6ab8da05c01ba6fbea630377b5da2cd9bcbc6338510116b01c1bc939a2c18"}, - {file = "kiwisolver-1.4.5-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:d2e5a98f0ec99beb3c10e13b387f8db39106d53993f498b295f0c914328b1333"}, - {file = "kiwisolver-1.4.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a51a263952b1429e429ff236d2f5a21c5125437861baeed77f5e1cc2d2c7c6da"}, - {file = "kiwisolver-1.4.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3edd2fa14e68c9be82c5b16689e8d63d89fe927e56debd6e1dbce7a26a17f81b"}, - {file = "kiwisolver-1.4.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:74d1b44c6cfc897df648cc9fdaa09bc3e7679926e6f96df05775d4fb3946571c"}, - {file = "kiwisolver-1.4.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:76d9289ed3f7501012e05abb8358bbb129149dbd173f1f57a1bf1c22d19ab7cc"}, - {file = "kiwisolver-1.4.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:92dea1ffe3714fa8eb6a314d2b3c773208d865a0e0d35e713ec54eea08a66250"}, - {file = "kiwisolver-1.4.5-cp38-cp38-win32.whl", hash = "sha256:5c90ae8c8d32e472be041e76f9d2f2dbff4d0b0be8bd4041770eddb18cf49a4e"}, - {file = "kiwisolver-1.4.5-cp38-cp38-win_amd64.whl", hash = "sha256:c7940c1dc63eb37a67721b10d703247552416f719c4188c54e04334321351ced"}, - {file = "kiwisolver-1.4.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9407b6a5f0d675e8a827ad8742e1d6b49d9c1a1da5d952a67d50ef5f4170b18d"}, - {file = "kiwisolver-1.4.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15568384086b6df3c65353820a4473575dbad192e35010f622c6ce3eebd57af9"}, - {file = "kiwisolver-1.4.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0dc9db8e79f0036e8173c466d21ef18e1befc02de8bf8aa8dc0813a6dc8a7046"}, - {file = "kiwisolver-1.4.5-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:cdc8a402aaee9a798b50d8b827d7ecf75edc5fb35ea0f91f213ff927c15f4ff0"}, - {file = "kiwisolver-1.4.5-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6c3bd3cde54cafb87d74d8db50b909705c62b17c2099b8f2e25b461882e544ff"}, - {file = "kiwisolver-1.4.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:955e8513d07a283056b1396e9a57ceddbd272d9252c14f154d450d227606eb54"}, - {file = "kiwisolver-1.4.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:346f5343b9e3f00b8db8ba359350eb124b98c99efd0b408728ac6ebf38173958"}, - {file = "kiwisolver-1.4.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b9098e0049e88c6a24ff64545cdfc50807818ba6c1b739cae221bbbcbc58aad3"}, - {file = "kiwisolver-1.4.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:00bd361b903dc4bbf4eb165f24d1acbee754fce22ded24c3d56eec268658a5cf"}, - {file = "kiwisolver-1.4.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7b8b454bac16428b22560d0a1cf0a09875339cab69df61d7805bf48919415901"}, - {file = "kiwisolver-1.4.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:f1d072c2eb0ad60d4c183f3fb44ac6f73fb7a8f16a2694a91f988275cbf352f9"}, - {file = "kiwisolver-1.4.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:31a82d498054cac9f6d0b53d02bb85811185bcb477d4b60144f915f3b3126342"}, - {file = "kiwisolver-1.4.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6512cb89e334e4700febbffaaa52761b65b4f5a3cf33f960213d5656cea36a77"}, - {file = "kiwisolver-1.4.5-cp39-cp39-win32.whl", hash = "sha256:9db8ea4c388fdb0f780fe91346fd438657ea602d58348753d9fb265ce1bca67f"}, - {file = "kiwisolver-1.4.5-cp39-cp39-win_amd64.whl", hash = "sha256:59415f46a37f7f2efeec758353dd2eae1b07640d8ca0f0c42548ec4125492635"}, - {file = "kiwisolver-1.4.5-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5c7b3b3a728dc6faf3fc372ef24f21d1e3cee2ac3e9596691d746e5a536de920"}, - {file = "kiwisolver-1.4.5-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:620ced262a86244e2be10a676b646f29c34537d0d9cc8eb26c08f53d98013390"}, - {file = "kiwisolver-1.4.5-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:378a214a1e3bbf5ac4a8708304318b4f890da88c9e6a07699c4ae7174c09a68d"}, - {file = "kiwisolver-1.4.5-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aaf7be1207676ac608a50cd08f102f6742dbfc70e8d60c4db1c6897f62f71523"}, - {file = "kiwisolver-1.4.5-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:ba55dce0a9b8ff59495ddd050a0225d58bd0983d09f87cfe2b6aec4f2c1234e4"}, - {file = "kiwisolver-1.4.5-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:fd32ea360bcbb92d28933fc05ed09bffcb1704ba3fc7942e81db0fd4f81a7892"}, - {file = "kiwisolver-1.4.5-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5e7139af55d1688f8b960ee9ad5adafc4ac17c1c473fe07133ac092310d76544"}, - {file = "kiwisolver-1.4.5-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:dced8146011d2bc2e883f9bd68618b8247387f4bbec46d7392b3c3b032640126"}, - {file = "kiwisolver-1.4.5-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9bf3325c47b11b2e51bca0824ea217c7cd84491d8ac4eefd1e409705ef092bd"}, - {file = "kiwisolver-1.4.5-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:5794cf59533bc3f1b1c821f7206a3617999db9fbefc345360aafe2e067514929"}, - {file = "kiwisolver-1.4.5-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:e368f200bbc2e4f905b8e71eb38b3c04333bddaa6a2464a6355487b02bb7fb09"}, - {file = "kiwisolver-1.4.5-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5d706eba36b4c4d5bc6c6377bb6568098765e990cfc21ee16d13963fab7b3e7"}, - {file = "kiwisolver-1.4.5-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85267bd1aa8880a9c88a8cb71e18d3d64d2751a790e6ca6c27b8ccc724bcd5ad"}, - {file = "kiwisolver-1.4.5-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:210ef2c3a1f03272649aff1ef992df2e724748918c4bc2d5a90352849eb40bea"}, - {file = "kiwisolver-1.4.5-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:11d011a7574eb3b82bcc9c1a1d35c1d7075677fdd15de527d91b46bd35e935ee"}, - {file = "kiwisolver-1.4.5.tar.gz", hash = "sha256:e57e563a57fb22a142da34f38acc2fc1a5c864bc29ca1517a88abc963e60d6ec"}, -] - -[[package]] -name = "lazy-loader" -version = "0.3" -description = "lazy_loader" -optional = false -python-versions = ">=3.7" -files = [ - {file = "lazy_loader-0.3-py3-none-any.whl", hash = "sha256:1e9e76ee8631e264c62ce10006718e80b2cfc74340d17d1031e0f84af7478554"}, - {file = "lazy_loader-0.3.tar.gz", hash = "sha256:3b68898e34f5b2a29daaaac172c6555512d0f32074f147e2254e4a6d9d838f37"}, -] - -[package.extras] -lint = ["pre-commit (>=3.3)"] -test = ["pytest (>=7.4)", "pytest-cov (>=4.1)"] - -[[package]] -name = "locust" -version = "2.32.6" -description = "Developer-friendly load testing framework" -optional = false -python-versions = ">=3.9" -files = [ - {file = "locust-2.32.6-py3-none-any.whl", hash = "sha256:d5c0e4f73134415d250087034431cf3ea42ca695d3dee7f10812287cacb6c4ef"}, - {file = "locust-2.32.6.tar.gz", hash = "sha256:6600cc308398e724764aacc56ccddf6cfcd0127c4c92dedd5c4979dd37ef5b15"}, -] - -[package.dependencies] -ConfigArgParse = ">=1.5.5" -flask = ">=2.0.0" -Flask-Cors = ">=3.0.10" -Flask-Login = ">=0.6.3" -gevent = [ - {version = ">=22.10.2", markers = "python_full_version <= \"3.12.0\""}, - {version = ">=24.10.1", markers = "python_full_version > \"3.13.0\""}, -] -geventhttpclient = ">=2.3.1" -msgpack = ">=1.0.0" -psutil = ">=5.9.1" -pywin32 = {version = "*", markers = "sys_platform == \"win32\""} -pyzmq = ">=25.0.0" -requests = [ - {version = ">=2.32.2", markers = "python_full_version > \"3.11.0\""}, - {version = ">=2.26.0", markers = "python_full_version <= \"3.11.0\""}, -] -setuptools = ">=70.0.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing_extensions = {version = ">=4.6.0", markers = "python_version < \"3.11\""} -Werkzeug = ">=2.0.0" - -[[package]] -name = "markdown-it-py" -version = "3.0.0" -description = "Python port of markdown-it. Markdown parsing, done right!" -optional = false -python-versions = ">=3.8" -files = [ - {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, - {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, -] - -[package.dependencies] -mdurl = ">=0.1,<1.0" - -[package.extras] -benchmarking = ["psutil", "pytest", "pytest-benchmark"] -code-style = ["pre-commit (>=3.0,<4.0)"] -compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] -linkify = ["linkify-it-py (>=1,<3)"] -plugins = ["mdit-py-plugins"] -profiling = ["gprof2dot"] -rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] - -[[package]] -name = "markupsafe" -version = "2.1.3" -description = "Safely add untrusted strings to HTML/XML markup." -optional = false -python-versions = ">=3.7" -files = [ - {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"}, - {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, -] - -[[package]] -name = "matplotlib" -version = "3.8.2" -description = "Python plotting package" -optional = false -python-versions = ">=3.9" -files = [ - {file = "matplotlib-3.8.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:09796f89fb71a0c0e1e2f4bdaf63fb2cefc84446bb963ecdeb40dfee7dfa98c7"}, - {file = "matplotlib-3.8.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6f9c6976748a25e8b9be51ea028df49b8e561eed7809146da7a47dbecebab367"}, - {file = "matplotlib-3.8.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b78e4f2cedf303869b782071b55fdde5987fda3038e9d09e58c91cc261b5ad18"}, - {file = "matplotlib-3.8.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e208f46cf6576a7624195aa047cb344a7f802e113bb1a06cfd4bee431de5e31"}, - {file = "matplotlib-3.8.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:46a569130ff53798ea5f50afce7406e91fdc471ca1e0e26ba976a8c734c9427a"}, - {file = "matplotlib-3.8.2-cp310-cp310-win_amd64.whl", hash = "sha256:830f00640c965c5b7f6bc32f0d4ce0c36dfe0379f7dd65b07a00c801713ec40a"}, - {file = "matplotlib-3.8.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d86593ccf546223eb75a39b44c32788e6f6440d13cfc4750c1c15d0fcb850b63"}, - {file = "matplotlib-3.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9a5430836811b7652991939012f43d2808a2db9b64ee240387e8c43e2e5578c8"}, - {file = "matplotlib-3.8.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9576723858a78751d5aacd2497b8aef29ffea6d1c95981505877f7ac28215c6"}, - {file = "matplotlib-3.8.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ba9cbd8ac6cf422f3102622b20f8552d601bf8837e49a3afed188d560152788"}, - {file = "matplotlib-3.8.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:03f9d160a29e0b65c0790bb07f4f45d6a181b1ac33eb1bb0dd225986450148f0"}, - {file = "matplotlib-3.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:3773002da767f0a9323ba1a9b9b5d00d6257dbd2a93107233167cfb581f64717"}, - {file = "matplotlib-3.8.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:4c318c1e95e2f5926fba326f68177dee364aa791d6df022ceb91b8221bd0a627"}, - {file = "matplotlib-3.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:091275d18d942cf1ee9609c830a1bc36610607d8223b1b981c37d5c9fc3e46a4"}, - {file = "matplotlib-3.8.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b0f3b8ea0e99e233a4bcc44590f01604840d833c280ebb8fe5554fd3e6cfe8d"}, - {file = "matplotlib-3.8.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7b1704a530395aaf73912be741c04d181f82ca78084fbd80bc737be04848331"}, - {file = "matplotlib-3.8.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:533b0e3b0c6768eef8cbe4b583731ce25a91ab54a22f830db2b031e83cca9213"}, - {file = "matplotlib-3.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:0f4fc5d72b75e2c18e55eb32292659cf731d9d5b312a6eb036506304f4675630"}, - {file = "matplotlib-3.8.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:deaed9ad4da0b1aea77fe0aa0cebb9ef611c70b3177be936a95e5d01fa05094f"}, - {file = "matplotlib-3.8.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:172f4d0fbac3383d39164c6caafd3255ce6fa58f08fc392513a0b1d3b89c4f89"}, - {file = "matplotlib-3.8.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7d36c2209d9136cd8e02fab1c0ddc185ce79bc914c45054a9f514e44c787917"}, - {file = "matplotlib-3.8.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5864bdd7da445e4e5e011b199bb67168cdad10b501750367c496420f2ad00843"}, - {file = "matplotlib-3.8.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ef8345b48e95cee45ff25192ed1f4857273117917a4dcd48e3905619bcd9c9b8"}, - {file = "matplotlib-3.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:7c48d9e221b637c017232e3760ed30b4e8d5dfd081daf327e829bf2a72c731b4"}, - {file = "matplotlib-3.8.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:aa11b3c6928a1e496c1a79917d51d4cd5d04f8a2e75f21df4949eeefdf697f4b"}, - {file = "matplotlib-3.8.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1095fecf99eeb7384dabad4bf44b965f929a5f6079654b681193edf7169ec20"}, - {file = "matplotlib-3.8.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:bddfb1db89bfaa855912261c805bd0e10218923cc262b9159a49c29a7a1c1afa"}, - {file = "matplotlib-3.8.2.tar.gz", hash = "sha256:01a978b871b881ee76017152f1f1a0cbf6bd5f7b8ff8c96df0df1bd57d8755a1"}, -] - -[package.dependencies] -contourpy = ">=1.0.1" -cycler = ">=0.10" -fonttools = ">=4.22.0" -kiwisolver = ">=1.3.1" -numpy = ">=1.21,<2" -packaging = ">=20.0" -pillow = ">=8" -pyparsing = ">=2.3.1" -python-dateutil = ">=2.7" - -[[package]] -name = "mdurl" -version = "0.1.2" -description = "Markdown URL utilities" -optional = false -python-versions = ">=3.7" -files = [ - {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, - {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, -] - -[[package]] -name = "mpmath" -version = "1.3.0" -description = "Python library for arbitrary-precision floating-point arithmetic" -optional = false -python-versions = "*" -files = [ - {file = "mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c"}, - {file = "mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f"}, -] - -[package.extras] -develop = ["codecov", "pycodestyle", "pytest (>=4.6)", "pytest-cov", "wheel"] -docs = ["sphinx"] -gmpy = ["gmpy2 (>=2.1.0a4)"] -tests = ["pytest (>=4.6)"] - -[[package]] -name = "msgpack" -version = "1.0.7" -description = "MessagePack serializer" -optional = false -python-versions = ">=3.8" -files = [ - {file = "msgpack-1.0.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04ad6069c86e531682f9e1e71b71c1c3937d6014a7c3e9edd2aa81ad58842862"}, - {file = "msgpack-1.0.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cca1b62fe70d761a282496b96a5e51c44c213e410a964bdffe0928e611368329"}, - {file = "msgpack-1.0.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e50ebce52f41370707f1e21a59514e3375e3edd6e1832f5e5235237db933c98b"}, - {file = "msgpack-1.0.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a7b4f35de6a304b5533c238bee86b670b75b03d31b7797929caa7a624b5dda6"}, - {file = "msgpack-1.0.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28efb066cde83c479dfe5a48141a53bc7e5f13f785b92ddde336c716663039ee"}, - {file = "msgpack-1.0.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4cb14ce54d9b857be9591ac364cb08dc2d6a5c4318c1182cb1d02274029d590d"}, - {file = "msgpack-1.0.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b573a43ef7c368ba4ea06050a957c2a7550f729c31f11dd616d2ac4aba99888d"}, - {file = "msgpack-1.0.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ccf9a39706b604d884d2cb1e27fe973bc55f2890c52f38df742bc1d79ab9f5e1"}, - {file = "msgpack-1.0.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cb70766519500281815dfd7a87d3a178acf7ce95390544b8c90587d76b227681"}, - {file = "msgpack-1.0.7-cp310-cp310-win32.whl", hash = "sha256:b610ff0f24e9f11c9ae653c67ff8cc03c075131401b3e5ef4b82570d1728f8a9"}, - {file = "msgpack-1.0.7-cp310-cp310-win_amd64.whl", hash = "sha256:a40821a89dc373d6427e2b44b572efc36a2778d3f543299e2f24eb1a5de65415"}, - {file = "msgpack-1.0.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:576eb384292b139821c41995523654ad82d1916da6a60cff129c715a6223ea84"}, - {file = "msgpack-1.0.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:730076207cb816138cf1af7f7237b208340a2c5e749707457d70705715c93b93"}, - {file = "msgpack-1.0.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:85765fdf4b27eb5086f05ac0491090fc76f4f2b28e09d9350c31aac25a5aaff8"}, - {file = "msgpack-1.0.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3476fae43db72bd11f29a5147ae2f3cb22e2f1a91d575ef130d2bf49afd21c46"}, - {file = "msgpack-1.0.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d4c80667de2e36970ebf74f42d1088cc9ee7ef5f4e8c35eee1b40eafd33ca5b"}, - {file = "msgpack-1.0.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b0bf0effb196ed76b7ad883848143427a73c355ae8e569fa538365064188b8e"}, - {file = "msgpack-1.0.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f9a7c509542db4eceed3dcf21ee5267ab565a83555c9b88a8109dcecc4709002"}, - {file = "msgpack-1.0.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:84b0daf226913133f899ea9b30618722d45feffa67e4fe867b0b5ae83a34060c"}, - {file = "msgpack-1.0.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ec79ff6159dffcc30853b2ad612ed572af86c92b5168aa3fc01a67b0fa40665e"}, - {file = "msgpack-1.0.7-cp311-cp311-win32.whl", hash = "sha256:3e7bf4442b310ff154b7bb9d81eb2c016b7d597e364f97d72b1acc3817a0fdc1"}, - {file = "msgpack-1.0.7-cp311-cp311-win_amd64.whl", hash = "sha256:3f0c8c6dfa6605ab8ff0611995ee30d4f9fcff89966cf562733b4008a3d60d82"}, - {file = "msgpack-1.0.7-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f0936e08e0003f66bfd97e74ee530427707297b0d0361247e9b4f59ab78ddc8b"}, - {file = "msgpack-1.0.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:98bbd754a422a0b123c66a4c341de0474cad4a5c10c164ceed6ea090f3563db4"}, - {file = "msgpack-1.0.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b291f0ee7961a597cbbcc77709374087fa2a9afe7bdb6a40dbbd9b127e79afee"}, - {file = "msgpack-1.0.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebbbba226f0a108a7366bf4b59bf0f30a12fd5e75100c630267d94d7f0ad20e5"}, - {file = "msgpack-1.0.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e2d69948e4132813b8d1131f29f9101bc2c915f26089a6d632001a5c1349672"}, - {file = "msgpack-1.0.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdf38ba2d393c7911ae989c3bbba510ebbcdf4ecbdbfec36272abe350c454075"}, - {file = "msgpack-1.0.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:993584fc821c58d5993521bfdcd31a4adf025c7d745bbd4d12ccfecf695af5ba"}, - {file = "msgpack-1.0.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:52700dc63a4676669b341ba33520f4d6e43d3ca58d422e22ba66d1736b0a6e4c"}, - {file = "msgpack-1.0.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e45ae4927759289c30ccba8d9fdce62bb414977ba158286b5ddaf8df2cddb5c5"}, - {file = "msgpack-1.0.7-cp312-cp312-win32.whl", hash = "sha256:27dcd6f46a21c18fa5e5deed92a43d4554e3df8d8ca5a47bf0615d6a5f39dbc9"}, - {file = "msgpack-1.0.7-cp312-cp312-win_amd64.whl", hash = "sha256:7687e22a31e976a0e7fc99c2f4d11ca45eff652a81eb8c8085e9609298916dcf"}, - {file = "msgpack-1.0.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5b6ccc0c85916998d788b295765ea0e9cb9aac7e4a8ed71d12e7d8ac31c23c95"}, - {file = "msgpack-1.0.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:235a31ec7db685f5c82233bddf9858748b89b8119bf4538d514536c485c15fe0"}, - {file = "msgpack-1.0.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cab3db8bab4b7e635c1c97270d7a4b2a90c070b33cbc00c99ef3f9be03d3e1f7"}, - {file = "msgpack-1.0.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bfdd914e55e0d2c9e1526de210f6fe8ffe9705f2b1dfcc4aecc92a4cb4b533d"}, - {file = "msgpack-1.0.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36e17c4592231a7dbd2ed09027823ab295d2791b3b1efb2aee874b10548b7524"}, - {file = "msgpack-1.0.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:38949d30b11ae5f95c3c91917ee7a6b239f5ec276f271f28638dec9156f82cfc"}, - {file = "msgpack-1.0.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ff1d0899f104f3921d94579a5638847f783c9b04f2d5f229392ca77fba5b82fc"}, - {file = "msgpack-1.0.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:dc43f1ec66eb8440567186ae2f8c447d91e0372d793dfe8c222aec857b81a8cf"}, - {file = "msgpack-1.0.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:dd632777ff3beaaf629f1ab4396caf7ba0bdd075d948a69460d13d44357aca4c"}, - {file = "msgpack-1.0.7-cp38-cp38-win32.whl", hash = "sha256:4e71bc4416de195d6e9b4ee93ad3f2f6b2ce11d042b4d7a7ee00bbe0358bd0c2"}, - {file = "msgpack-1.0.7-cp38-cp38-win_amd64.whl", hash = "sha256:8f5b234f567cf76ee489502ceb7165c2a5cecec081db2b37e35332b537f8157c"}, - {file = "msgpack-1.0.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bfef2bb6ef068827bbd021017a107194956918ab43ce4d6dc945ffa13efbc25f"}, - {file = "msgpack-1.0.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:484ae3240666ad34cfa31eea7b8c6cd2f1fdaae21d73ce2974211df099a95d81"}, - {file = "msgpack-1.0.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3967e4ad1aa9da62fd53e346ed17d7b2e922cba5ab93bdd46febcac39be636fc"}, - {file = "msgpack-1.0.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8dd178c4c80706546702c59529ffc005681bd6dc2ea234c450661b205445a34d"}, - {file = "msgpack-1.0.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6ffbc252eb0d229aeb2f9ad051200668fc3a9aaa8994e49f0cb2ffe2b7867e7"}, - {file = "msgpack-1.0.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:822ea70dc4018c7e6223f13affd1c5c30c0f5c12ac1f96cd8e9949acddb48a61"}, - {file = "msgpack-1.0.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:384d779f0d6f1b110eae74cb0659d9aa6ff35aaf547b3955abf2ab4c901c4819"}, - {file = "msgpack-1.0.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f64e376cd20d3f030190e8c32e1c64582eba56ac6dc7d5b0b49a9d44021b52fd"}, - {file = "msgpack-1.0.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5ed82f5a7af3697b1c4786053736f24a0efd0a1b8a130d4c7bfee4b9ded0f08f"}, - {file = "msgpack-1.0.7-cp39-cp39-win32.whl", hash = "sha256:f26a07a6e877c76a88e3cecac8531908d980d3d5067ff69213653649ec0f60ad"}, - {file = "msgpack-1.0.7-cp39-cp39-win_amd64.whl", hash = "sha256:1dc93e8e4653bdb5910aed79f11e165c85732067614f180f70534f056da97db3"}, - {file = "msgpack-1.0.7.tar.gz", hash = "sha256:572efc93db7a4d27e404501975ca6d2d9775705c2d922390d878fcf768d92c87"}, -] - -[[package]] -name = "mypy" -version = "1.14.1" -description = "Optional static typing for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "mypy-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:52686e37cf13d559f668aa398dd7ddf1f92c5d613e4f8cb262be2fb4fedb0fcb"}, - {file = "mypy-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1fb545ca340537d4b45d3eecdb3def05e913299ca72c290326be19b3804b39c0"}, - {file = "mypy-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90716d8b2d1f4cd503309788e51366f07c56635a3309b0f6a32547eaaa36a64d"}, - {file = "mypy-1.14.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ae753f5c9fef278bcf12e1a564351764f2a6da579d4a81347e1d5a15819997b"}, - {file = "mypy-1.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0fe0f5feaafcb04505bcf439e991c6d8f1bf8b15f12b05feeed96e9e7bf1427"}, - {file = "mypy-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:7d54bd85b925e501c555a3227f3ec0cfc54ee8b6930bd6141ec872d1c572f81f"}, - {file = "mypy-1.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f995e511de847791c3b11ed90084a7a0aafdc074ab88c5a9711622fe4751138c"}, - {file = "mypy-1.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d64169ec3b8461311f8ce2fd2eb5d33e2d0f2c7b49116259c51d0d96edee48d1"}, - {file = "mypy-1.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba24549de7b89b6381b91fbc068d798192b1b5201987070319889e93038967a8"}, - {file = "mypy-1.14.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:183cf0a45457d28ff9d758730cd0210419ac27d4d3f285beda038c9083363b1f"}, - {file = "mypy-1.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f2a0ecc86378f45347f586e4163d1769dd81c5a223d577fe351f26b179e148b1"}, - {file = "mypy-1.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:ad3301ebebec9e8ee7135d8e3109ca76c23752bac1e717bc84cd3836b4bf3eae"}, - {file = "mypy-1.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30ff5ef8519bbc2e18b3b54521ec319513a26f1bba19a7582e7b1f58a6e69f14"}, - {file = "mypy-1.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb9f255c18052343c70234907e2e532bc7e55a62565d64536dbc7706a20b78b9"}, - {file = "mypy-1.14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b4e3413e0bddea671012b063e27591b953d653209e7a4fa5e48759cda77ca11"}, - {file = "mypy-1.14.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:553c293b1fbdebb6c3c4030589dab9fafb6dfa768995a453d8a5d3b23784af2e"}, - {file = "mypy-1.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fad79bfe3b65fe6a1efaed97b445c3d37f7be9fdc348bdb2d7cac75579607c89"}, - {file = "mypy-1.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:8fa2220e54d2946e94ab6dbb3ba0a992795bd68b16dc852db33028df2b00191b"}, - {file = "mypy-1.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:92c3ed5afb06c3a8e188cb5da4984cab9ec9a77ba956ee419c68a388b4595255"}, - {file = "mypy-1.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dbec574648b3e25f43d23577309b16534431db4ddc09fda50841f1e34e64ed34"}, - {file = "mypy-1.14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c6d94b16d62eb3e947281aa7347d78236688e21081f11de976376cf010eb31a"}, - {file = "mypy-1.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4b19b03fdf54f3c5b2fa474c56b4c13c9dbfb9a2db4370ede7ec11a2c5927d9"}, - {file = "mypy-1.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c911fde686394753fff899c409fd4e16e9b294c24bfd5e1ea4675deae1ac6fd"}, - {file = "mypy-1.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:8b21525cb51671219f5307be85f7e646a153e5acc656e5cebf64bfa076c50107"}, - {file = "mypy-1.14.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7084fb8f1128c76cd9cf68fe5971b37072598e7c31b2f9f95586b65c741a9d31"}, - {file = "mypy-1.14.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8f845a00b4f420f693f870eaee5f3e2692fa84cc8514496114649cfa8fd5e2c6"}, - {file = "mypy-1.14.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44bf464499f0e3a2d14d58b54674dee25c031703b2ffc35064bd0df2e0fac319"}, - {file = "mypy-1.14.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c99f27732c0b7dc847adb21c9d47ce57eb48fa33a17bc6d7d5c5e9f9e7ae5bac"}, - {file = "mypy-1.14.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:bce23c7377b43602baa0bd22ea3265c49b9ff0b76eb315d6c34721af4cdf1d9b"}, - {file = "mypy-1.14.1-cp38-cp38-win_amd64.whl", hash = "sha256:8edc07eeade7ebc771ff9cf6b211b9a7d93687ff892150cb5692e4f4272b0837"}, - {file = "mypy-1.14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3888a1816d69f7ab92092f785a462944b3ca16d7c470d564165fe703b0970c35"}, - {file = "mypy-1.14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:46c756a444117c43ee984bd055db99e498bc613a70bbbc120272bd13ca579fbc"}, - {file = "mypy-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:27fc248022907e72abfd8e22ab1f10e903915ff69961174784a3900a8cba9ad9"}, - {file = "mypy-1.14.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:499d6a72fb7e5de92218db961f1a66d5f11783f9ae549d214617edab5d4dbdbb"}, - {file = "mypy-1.14.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:57961db9795eb566dc1d1b4e9139ebc4c6b0cb6e7254ecde69d1552bf7613f60"}, - {file = "mypy-1.14.1-cp39-cp39-win_amd64.whl", hash = "sha256:07ba89fdcc9451f2ebb02853deb6aaaa3d2239a236669a63ab3801bbf923ef5c"}, - {file = "mypy-1.14.1-py3-none-any.whl", hash = "sha256:b66a60cc4073aeb8ae00057f9c1f64d49e90f918fbcef9a977eb121da8b8f1d1"}, - {file = "mypy-1.14.1.tar.gz", hash = "sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6"}, -] - -[package.dependencies] -mypy_extensions = ">=1.0.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing_extensions = ">=4.6.0" - -[package.extras] -dmypy = ["psutil (>=4.0)"] -faster-cache = ["orjson"] -install-types = ["pip"] -mypyc = ["setuptools (>=50)"] -reports = ["lxml"] - -[[package]] -name = "mypy-extensions" -version = "1.0.0" -description = "Type system extensions for programs checked with the mypy type checker." -optional = false -python-versions = ">=3.5" -files = [ - {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, - {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, -] - -[[package]] -name = "networkx" -version = "3.2.1" -description = "Python package for creating and manipulating graphs and networks" -optional = false -python-versions = ">=3.9" -files = [ - {file = "networkx-3.2.1-py3-none-any.whl", hash = "sha256:f18c69adc97877c42332c170849c96cefa91881c99a7cb3e95b7c659ebdc1ec2"}, - {file = "networkx-3.2.1.tar.gz", hash = "sha256:9f1bb5cf3409bf324e0a722c20bdb4c20ee39bf1c30ce8ae499c8502b0b5e0c6"}, -] - -[package.extras] -default = ["matplotlib (>=3.5)", "numpy (>=1.22)", "pandas (>=1.4)", "scipy (>=1.9,!=1.11.0,!=1.11.1)"] -developer = ["changelist (==0.4)", "mypy (>=1.1)", "pre-commit (>=3.2)", "rtoml"] -doc = ["nb2plots (>=0.7)", "nbconvert (<7.9)", "numpydoc (>=1.6)", "pillow (>=9.4)", "pydata-sphinx-theme (>=0.14)", "sphinx (>=7)", "sphinx-gallery (>=0.14)", "texext (>=0.6.7)"] -extra = ["lxml (>=4.6)", "pydot (>=1.4.2)", "pygraphviz (>=1.11)", "sympy (>=1.10)"] -test = ["pytest (>=7.2)", "pytest-cov (>=4.0)"] - -[[package]] -name = "numpy" -version = "1.26.4" -description = "Fundamental package for array computing in Python" -optional = false -python-versions = ">=3.9" -files = [ - {file = "numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"}, - {file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"}, - {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4"}, - {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f"}, - {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a"}, - {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2"}, - {file = "numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07"}, - {file = "numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5"}, - {file = "numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71"}, - {file = "numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef"}, - {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e"}, - {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5"}, - {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a"}, - {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a"}, - {file = "numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20"}, - {file = "numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2"}, - {file = "numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218"}, - {file = "numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b"}, - {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b"}, - {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed"}, - {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a"}, - {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0"}, - {file = "numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110"}, - {file = "numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818"}, - {file = "numpy-1.26.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7349ab0fa0c429c82442a27a9673fc802ffdb7c7775fad780226cb234965e53c"}, - {file = "numpy-1.26.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:52b8b60467cd7dd1e9ed082188b4e6bb35aa5cdd01777621a1658910745b90be"}, - {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5241e0a80d808d70546c697135da2c613f30e28251ff8307eb72ba696945764"}, - {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f870204a840a60da0b12273ef34f7051e98c3b5961b61b0c2c1be6dfd64fbcd3"}, - {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:679b0076f67ecc0138fd2ede3a8fd196dddc2ad3254069bcb9faf9a79b1cebcd"}, - {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:47711010ad8555514b434df65f7d7b076bb8261df1ca9bb78f53d3b2db02e95c"}, - {file = "numpy-1.26.4-cp39-cp39-win32.whl", hash = "sha256:a354325ee03388678242a4d7ebcd08b5c727033fcff3b2f536aea978e15ee9e6"}, - {file = "numpy-1.26.4-cp39-cp39-win_amd64.whl", hash = "sha256:3373d5d70a5fe74a2c1bb6d2cfd9609ecf686d47a2d7b1d37a8f3b6bf6003aea"}, - {file = "numpy-1.26.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:afedb719a9dcfc7eaf2287b839d8198e06dcd4cb5d276a3df279231138e83d30"}, - {file = "numpy-1.26.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95a7476c59002f2f6c590b9b7b998306fba6a5aa646b1e22ddfeaf8f78c3a29c"}, - {file = "numpy-1.26.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7e50d0a0cc3189f9cb0aeb3a6a6af18c16f59f004b866cd2be1c14b36134a4a0"}, - {file = "numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010"}, -] - -[[package]] -name = "onnx" -version = "1.16.0" -description = "Open Neural Network Exchange" -optional = false -python-versions = ">=3.8" -files = [ - {file = "onnx-1.16.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:9eadbdce25b19d6216f426d6d99b8bc877a65ed92cbef9707751c6669190ba4f"}, - {file = "onnx-1.16.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:034ae21a2aaa2e9c14119a840d2926d213c27aad29e5e3edaa30145a745048e1"}, - {file = "onnx-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec22a43d74eb1f2303373e2fbe7fbcaa45fb225f4eb146edfed1356ada7a9aea"}, - {file = "onnx-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:298f28a2b5ac09145fa958513d3d1e6b349ccf86a877dbdcccad57713fe360b3"}, - {file = "onnx-1.16.0-cp310-cp310-win32.whl", hash = "sha256:66300197b52beca08bc6262d43c103289c5d45fde43fb51922ed1eb83658cf0c"}, - {file = "onnx-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:ae0029f5e47bf70a1a62e7f88c80bca4ef39b844a89910039184221775df5e43"}, - {file = "onnx-1.16.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:f51179d4af3372b4f3800c558d204b592c61e4b4a18b8f61e0eea7f46211221a"}, - {file = "onnx-1.16.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:5202559070afec5144332db216c20f2fff8323cf7f6512b0ca11b215eacc5bf3"}, - {file = "onnx-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77579e7c15b4df39d29465b216639a5f9b74026bdd9e4b6306cd19a32dcfe67c"}, - {file = "onnx-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e60ca76ac24b65c25860d0f2d2cdd96d6320d062a01dd8ce87c5743603789b8"}, - {file = "onnx-1.16.0-cp311-cp311-win32.whl", hash = "sha256:81b4ee01bc554e8a2b11ac6439882508a5377a1c6b452acd69a1eebb83571117"}, - {file = "onnx-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:7449241e70b847b9c3eb8dae622df8c1b456d11032a9d7e26e0ee8a698d5bf86"}, - {file = "onnx-1.16.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:03a627488b1a9975d95d6a55582af3e14c7f3bb87444725b999935ddd271d352"}, - {file = "onnx-1.16.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c392faeabd9283ee344ccb4b067d1fea9dfc614fa1f0de7c47589efd79e15e78"}, - {file = "onnx-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0efeb46985de08f0efe758cb54ad3457e821a05c2eaf5ba2ccb8cd1602c08084"}, - {file = "onnx-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ddf14a3d32234f23e44abb73a755cb96a423fac7f004e8f046f36b10214151ee"}, - {file = "onnx-1.16.0-cp312-cp312-win32.whl", hash = "sha256:62a2e27ae8ba5fc9b4a2620301446a517b5ffaaf8566611de7a7c2160f5bcf4c"}, - {file = "onnx-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:3e0860fea94efde777e81a6f68f65761ed5e5f3adea2e050d7fbe373a9ae05b3"}, - {file = "onnx-1.16.0-cp38-cp38-macosx_10_15_universal2.whl", hash = "sha256:70a90649318f3470985439ea078277c9fb2a2e6e2fd7c8f3f2b279402ad6c7e6"}, - {file = "onnx-1.16.0-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:71839546b7f93be4fa807995b182ab4b4414c9dbf049fee11eaaced16fcf8df2"}, - {file = "onnx-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7665217c45a61eb44718c8e9349d2ad004efa0cb9fbc4be5c6d5e18b9fe12b52"}, - {file = "onnx-1.16.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5752bbbd5717304a7643643dba383a2fb31e8eb0682f4e7b7d141206328a73b"}, - {file = "onnx-1.16.0-cp38-cp38-win32.whl", hash = "sha256:257858cbcb2055284f09fa2ae2b1cfd64f5850367da388d6e7e7b05920a40c90"}, - {file = "onnx-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:209fe84995a28038e29ae8369edd35f33e0ef1ebc3bddbf6584629823469deb1"}, - {file = "onnx-1.16.0-cp39-cp39-macosx_10_15_universal2.whl", hash = "sha256:8cf3e518b1b1b960be542e7c62bed4e5219e04c85d540817b7027029537dec92"}, - {file = "onnx-1.16.0-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:30f02beaf081c7d9fa3a8c566a912fc4408e28fc33b1452d58f890851691d364"}, - {file = "onnx-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fb29a9a692b522deef1f6b8f2145da62c0c43ea1ed5b4c0f66f827fdc28847d"}, - {file = "onnx-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7755cbd5f4e47952e37276ea5978a46fc8346684392315902b5ed4a719d87d06"}, - {file = "onnx-1.16.0-cp39-cp39-win32.whl", hash = "sha256:7532343dc5b8b5e7c3e3efa441a3100552f7600155c4db9120acd7574f64ffbf"}, - {file = "onnx-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:d7886c05aa6d583ec42f6287678923c1e343afc4350e49d5b36a0023772ffa22"}, - {file = "onnx-1.16.0.tar.gz", hash = "sha256:237c6987c6c59d9f44b6136f5819af79574f8d96a760a1fa843bede11f3822f7"}, -] - -[package.dependencies] -numpy = ">=1.20" -protobuf = ">=3.20.2" - -[package.extras] -reference = ["Pillow", "google-re2"] - -[[package]] -name = "onnxruntime" -version = "1.20.1" -description = "ONNX Runtime is a runtime accelerator for Machine Learning models" -optional = false -python-versions = "*" -files = [ - {file = "onnxruntime-1.20.1-cp310-cp310-macosx_13_0_universal2.whl", hash = "sha256:e50ba5ff7fed4f7d9253a6baf801ca2883cc08491f9d32d78a80da57256a5439"}, - {file = "onnxruntime-1.20.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b2908b50101a19e99c4d4e97ebb9905561daf61829403061c1adc1b588bc0de"}, - {file = "onnxruntime-1.20.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d82daaec24045a2e87598b8ac2b417b1cce623244e80e663882e9fe1aae86410"}, - {file = "onnxruntime-1.20.1-cp310-cp310-win32.whl", hash = "sha256:4c4b251a725a3b8cf2aab284f7d940c26094ecd9d442f07dd81ab5470e99b83f"}, - {file = "onnxruntime-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:d3b616bb53a77a9463707bb313637223380fc327f5064c9a782e8ec69c22e6a2"}, - {file = "onnxruntime-1.20.1-cp311-cp311-macosx_13_0_universal2.whl", hash = "sha256:06bfbf02ca9ab5f28946e0f912a562a5f005301d0c419283dc57b3ed7969bb7b"}, - {file = "onnxruntime-1.20.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6243e34d74423bdd1edf0ae9596dd61023b260f546ee17d701723915f06a9f7"}, - {file = "onnxruntime-1.20.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5eec64c0269dcdb8d9a9a53dc4d64f87b9e0c19801d9321246a53b7eb5a7d1bc"}, - {file = "onnxruntime-1.20.1-cp311-cp311-win32.whl", hash = "sha256:a19bc6e8c70e2485a1725b3d517a2319603acc14c1f1a017dda0afe6d4665b41"}, - {file = "onnxruntime-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:8508887eb1c5f9537a4071768723ec7c30c28eb2518a00d0adcd32c89dea3221"}, - {file = "onnxruntime-1.20.1-cp312-cp312-macosx_13_0_universal2.whl", hash = "sha256:22b0655e2bf4f2161d52706e31f517a0e54939dc393e92577df51808a7edc8c9"}, - {file = "onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f56e898815963d6dc4ee1c35fc6c36506466eff6d16f3cb9848cea4e8c8172"}, - {file = "onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb71a814f66517a65628c9e4a2bb530a6edd2cd5d87ffa0af0f6f773a027d99e"}, - {file = "onnxruntime-1.20.1-cp312-cp312-win32.whl", hash = "sha256:bd386cc9ee5f686ee8a75ba74037750aca55183085bf1941da8efcfe12d5b120"}, - {file = "onnxruntime-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:19c2d843eb074f385e8bbb753a40df780511061a63f9def1b216bf53860223fb"}, - {file = "onnxruntime-1.20.1-cp313-cp313-macosx_13_0_universal2.whl", hash = "sha256:cc01437a32d0042b606f462245c8bbae269e5442797f6213e36ce61d5abdd8cc"}, - {file = "onnxruntime-1.20.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb44b08e017a648924dbe91b82d89b0c105b1adcfe31e90d1dc06b8677ad37be"}, - {file = "onnxruntime-1.20.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bda6aebdf7917c1d811f21d41633df00c58aff2bef2f598f69289c1f1dabc4b3"}, - {file = "onnxruntime-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:d30367df7e70f1d9fc5a6a68106f5961686d39b54d3221f760085524e8d38e16"}, - {file = "onnxruntime-1.20.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9158465745423b2b5d97ed25aa7740c7d38d2993ee2e5c3bfacb0c4145c49d8"}, - {file = "onnxruntime-1.20.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0df6f2df83d61f46e842dbcde610ede27218947c33e994545a22333491e72a3b"}, -] - -[package.dependencies] -coloredlogs = "*" -flatbuffers = "*" -numpy = ">=1.21.6" -packaging = "*" -protobuf = "*" -sympy = "*" - -[[package]] -name = "onnxruntime-gpu" -version = "1.19.2" -description = "ONNX Runtime is a runtime accelerator for Machine Learning models" -optional = false -python-versions = "*" -files = [ - {file = "onnxruntime_gpu-1.19.2-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a49740e079e7c5215830d30cde3df792e903df007aa0b0fd7aa797937061b27a"}, - {file = "onnxruntime_gpu-1.19.2-cp310-cp310-win_amd64.whl", hash = "sha256:b895920bb5e4241299f68874e0becdc2635ea0142939c11e7ff5ae5b28993613"}, - {file = "onnxruntime_gpu-1.19.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:562fc7c755393eaad9751e56149339dd201ffbfdb3ef5f43ff21d0619ba9045f"}, - {file = "onnxruntime_gpu-1.19.2-cp311-cp311-win_amd64.whl", hash = "sha256:522f7495918176cb8c1a3c78bde7152d984f7096acc786c73a27643af8af87c9"}, - {file = "onnxruntime_gpu-1.19.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:554a02a3fac0119707eb87327908afd21c4e6f0fa5bf9a034398f098adc316c5"}, - {file = "onnxruntime_gpu-1.19.2-cp312-cp312-win_amd64.whl", hash = "sha256:e7c6165a405027e3c0f11d189ae7013b5d66919b3381f9bfb3405c0c0cf07968"}, - {file = "onnxruntime_gpu-1.19.2-cp38-cp38-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a8562e1e6f1912870c60bfaf8233c82b86e5b93ae39f211b650ac0f2015430"}, - {file = "onnxruntime_gpu-1.19.2-cp38-cp38-win_amd64.whl", hash = "sha256:55505c99e18688a7c68fdc811ed6e7a315aa36f543b33920c77d03a627d2c3f5"}, - {file = "onnxruntime_gpu-1.19.2-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9e369f01f55ea726ae5d28f18236426e52e97c433f0b7682054e61c478a06c9"}, - {file = "onnxruntime_gpu-1.19.2-cp39-cp39-win_amd64.whl", hash = "sha256:c8b8128174b0470537e9f4983aeecc002a435d13914970c2af2f41d244ef2781"}, -] - -[package.dependencies] -coloredlogs = "*" -flatbuffers = "*" -numpy = ">=1.21.6" -packaging = "*" -protobuf = "*" -sympy = "*" - -[package.source] -type = "legacy" -url = "https://aiinfra.pkgs.visualstudio.com/PublicPackages/_packaging/onnxruntime-cuda-12/pypi/simple" -reference = "cuda12" - -[[package]] -name = "onnxruntime-openvino" -version = "1.18.0" -description = "ONNX Runtime is a runtime accelerator for Machine Learning models" -optional = false -python-versions = "*" -files = [ - {file = "onnxruntime_openvino-1.18.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:565b874d21bcd48126da7d62f57db019f5ec0e1f82ae9b0740afa2ad91f8d331"}, - {file = "onnxruntime_openvino-1.18.0-cp310-cp310-win_amd64.whl", hash = "sha256:7f1931060f710a6c8e32121bb73044c4772ef5925802fc8776d3fe1e87ab3f75"}, - {file = "onnxruntime_openvino-1.18.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:eb1723d386f70a8e26398d983ebe35d2c25ba56e9cdb382670ebbf1f5139f8ba"}, - {file = "onnxruntime_openvino-1.18.0-cp311-cp311-win_amd64.whl", hash = "sha256:874a1e263dd86674593e5a879257650b06a8609c4d5768c3d8ed8dc4ae874b9c"}, - {file = "onnxruntime_openvino-1.18.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:597eb18f3de7ead69b08a242d74c4573b28bbfba40ca2a1a40f75bf7a834808e"}, -] - -[package.dependencies] -coloredlogs = "*" -flatbuffers = "*" -numpy = ">=1.26.4" -packaging = "*" -protobuf = "*" -sympy = "*" - -[[package]] -name = "opencv-python-headless" -version = "4.11.0.86" -description = "Wrapper package for OpenCV python bindings." -optional = false -python-versions = ">=3.6" -files = [ - {file = "opencv-python-headless-4.11.0.86.tar.gz", hash = "sha256:996eb282ca4b43ec6a3972414de0e2331f5d9cda2b41091a49739c19fb843798"}, - {file = "opencv_python_headless-4.11.0.86-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:48128188ade4a7e517237c8e1e11a9cdf5c282761473383e77beb875bb1e61ca"}, - {file = "opencv_python_headless-4.11.0.86-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:a66c1b286a9de872c343ee7c3553b084244299714ebb50fbdcd76f07ebbe6c81"}, - {file = "opencv_python_headless-4.11.0.86-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6efabcaa9df731f29e5ea9051776715b1bdd1845d7c9530065c7951d2a2899eb"}, - {file = "opencv_python_headless-4.11.0.86-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e0a27c19dd1f40ddff94976cfe43066fbbe9dfbb2ec1907d66c19caef42a57b"}, - {file = "opencv_python_headless-4.11.0.86-cp37-abi3-win32.whl", hash = "sha256:f447d8acbb0b6f2808da71fddd29c1cdd448d2bc98f72d9bb78a7a898fc9621b"}, - {file = "opencv_python_headless-4.11.0.86-cp37-abi3-win_amd64.whl", hash = "sha256:6c304df9caa7a6a5710b91709dd4786bf20a74d57672b3c31f7033cc638174ca"}, -] - -[package.dependencies] -numpy = [ - {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, - {version = ">=1.23.5", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, - {version = ">=1.21.4", markers = "python_version >= \"3.10\" and platform_system == \"Darwin\" and python_version < \"3.11\""}, - {version = ">=1.21.2", markers = "platform_system != \"Darwin\" and python_version >= \"3.10\" and python_version < \"3.11\""}, -] - -[[package]] -name = "orjson" -version = "3.10.14" -description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" -optional = false -python-versions = ">=3.8" -files = [ - {file = "orjson-3.10.14-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:849ea7845a55f09965826e816cdc7689d6cf74fe9223d79d758c714af955bcb6"}, - {file = "orjson-3.10.14-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5947b139dfa33f72eecc63f17e45230a97e741942955a6c9e650069305eb73d"}, - {file = "orjson-3.10.14-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cde6d76910d3179dae70f164466692f4ea36da124d6fb1a61399ca589e81d69a"}, - {file = "orjson-3.10.14-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6dfbaeb7afa77ca608a50e2770a0461177b63a99520d4928e27591b142c74b1"}, - {file = "orjson-3.10.14-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa45e489ef80f28ff0e5ba0a72812b8cfc7c1ef8b46a694723807d1b07c89ebb"}, - {file = "orjson-3.10.14-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f5007abfdbb1d866e2aa8990bd1c465f0f6da71d19e695fc278282be12cffa5"}, - {file = "orjson-3.10.14-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1b49e2af011c84c3f2d541bb5cd1e3c7c2df672223e7e3ea608f09cf295e5f8a"}, - {file = "orjson-3.10.14-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:164ac155109226b3a2606ee6dda899ccfbe6e7e18b5bdc3fbc00f79cc074157d"}, - {file = "orjson-3.10.14-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6b1225024cf0ef5d15934b5ffe9baf860fe8bc68a796513f5ea4f5056de30bca"}, - {file = "orjson-3.10.14-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d6546e8073dc382e60fcae4a001a5a1bc46da5eab4a4878acc2d12072d6166d5"}, - {file = "orjson-3.10.14-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9f1d2942605c894162252d6259b0121bf1cb493071a1ea8cb35d79cb3e6ac5bc"}, - {file = "orjson-3.10.14-cp310-cp310-win32.whl", hash = "sha256:397083806abd51cf2b3bbbf6c347575374d160331a2d33c5823e22249ad3118b"}, - {file = "orjson-3.10.14-cp310-cp310-win_amd64.whl", hash = "sha256:fa18f949d3183a8d468367056be989666ac2bef3a72eece0bade9cdb733b3c28"}, - {file = "orjson-3.10.14-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f506fd666dd1ecd15a832bebc66c4df45c1902fd47526292836c339f7ba665a9"}, - {file = "orjson-3.10.14-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efe5fd254cfb0eeee13b8ef7ecb20f5d5a56ddda8a587f3852ab2cedfefdb5f6"}, - {file = "orjson-3.10.14-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ddc8c866d7467f5ee2991397d2ea94bcf60d0048bdd8ca555740b56f9042725"}, - {file = "orjson-3.10.14-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3af8e42ae4363773658b8d578d56dedffb4f05ceeb4d1d4dd3fb504950b45526"}, - {file = "orjson-3.10.14-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84dd83110503bc10e94322bf3ffab8bc49150176b49b4984dc1cce4c0a993bf9"}, - {file = "orjson-3.10.14-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36f5bfc0399cd4811bf10ec7a759c7ab0cd18080956af8ee138097d5b5296a95"}, - {file = "orjson-3.10.14-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:868943660fb2a1e6b6b965b74430c16a79320b665b28dd4511d15ad5038d37d5"}, - {file = "orjson-3.10.14-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:33449c67195969b1a677533dee9d76e006001213a24501333624623e13c7cc8e"}, - {file = "orjson-3.10.14-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e4c9f60f9fb0b5be66e416dcd8c9d94c3eabff3801d875bdb1f8ffc12cf86905"}, - {file = "orjson-3.10.14-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0de4d6315cfdbd9ec803b945c23b3a68207fd47cbe43626036d97e8e9561a436"}, - {file = "orjson-3.10.14-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83adda3db595cb1a7e2237029b3249c85afbe5c747d26b41b802e7482cb3933e"}, - {file = "orjson-3.10.14-cp311-cp311-win32.whl", hash = "sha256:998019ef74a4997a9d741b1473533cdb8faa31373afc9849b35129b4b8ec048d"}, - {file = "orjson-3.10.14-cp311-cp311-win_amd64.whl", hash = "sha256:9d034abdd36f0f0f2240f91492684e5043d46f290525d1117712d5b8137784eb"}, - {file = "orjson-3.10.14-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:2ad4b7e367efba6dc3f119c9a0fcd41908b7ec0399a696f3cdea7ec477441b09"}, - {file = "orjson-3.10.14-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f496286fc85e93ce0f71cc84fc1c42de2decf1bf494094e188e27a53694777a7"}, - {file = "orjson-3.10.14-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7f189bbfcded40e41a6969c1068ba305850ba016665be71a217918931416fbf"}, - {file = "orjson-3.10.14-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8cc8204f0b75606869c707da331058ddf085de29558b516fc43c73ee5ee2aadb"}, - {file = "orjson-3.10.14-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:deaa2899dff7f03ab667e2ec25842d233e2a6a9e333efa484dfe666403f3501c"}, - {file = "orjson-3.10.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1c3ea52642c9714dc6e56de8a451a066f6d2707d273e07fe8a9cc1ba073813d"}, - {file = "orjson-3.10.14-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9d3f9ed72e7458ded9a1fb1b4d4ed4c4fdbaf82030ce3f9274b4dc1bff7ace2b"}, - {file = "orjson-3.10.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:07520685d408a2aba514c17ccc16199ff2934f9f9e28501e676c557f454a37fe"}, - {file = "orjson-3.10.14-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:76344269b550ea01488d19a2a369ab572c1ac4449a72e9f6ac0d70eb1cbfb953"}, - {file = "orjson-3.10.14-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e2979d0f2959990620f7e62da6cd954e4620ee815539bc57a8ae46e2dacf90e3"}, - {file = "orjson-3.10.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03f61ca3674555adcb1aa717b9fc87ae936aa7a63f6aba90a474a88701278780"}, - {file = "orjson-3.10.14-cp312-cp312-win32.whl", hash = "sha256:d5075c54edf1d6ad81d4c6523ce54a748ba1208b542e54b97d8a882ecd810fd1"}, - {file = "orjson-3.10.14-cp312-cp312-win_amd64.whl", hash = "sha256:175cafd322e458603e8ce73510a068d16b6e6f389c13f69bf16de0e843d7d406"}, - {file = "orjson-3.10.14-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:0905ca08a10f7e0e0c97d11359609300eb1437490a7f32bbaa349de757e2e0c7"}, - {file = "orjson-3.10.14-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92d13292249f9f2a3e418cbc307a9fbbef043c65f4bd8ba1eb620bc2aaba3d15"}, - {file = "orjson-3.10.14-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90937664e776ad316d64251e2fa2ad69265e4443067668e4727074fe39676414"}, - {file = "orjson-3.10.14-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9ed3d26c4cb4f6babaf791aa46a029265850e80ec2a566581f5c2ee1a14df4f1"}, - {file = "orjson-3.10.14-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:56ee546c2bbe9599aba78169f99d1dc33301853e897dbaf642d654248280dc6e"}, - {file = "orjson-3.10.14-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:901e826cb2f1bdc1fcef3ef59adf0c451e8f7c0b5deb26c1a933fb66fb505eae"}, - {file = "orjson-3.10.14-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:26336c0d4b2d44636e1e1e6ed1002f03c6aae4a8a9329561c8883f135e9ff010"}, - {file = "orjson-3.10.14-cp313-cp313-win32.whl", hash = "sha256:e2bc525e335a8545c4e48f84dd0328bc46158c9aaeb8a1c2276546e94540ea3d"}, - {file = "orjson-3.10.14-cp313-cp313-win_amd64.whl", hash = "sha256:eca04dfd792cedad53dc9a917da1a522486255360cb4e77619343a20d9f35364"}, - {file = "orjson-3.10.14-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9a0fba3b8a587a54c18585f077dcab6dd251c170d85cfa4d063d5746cd595a0f"}, - {file = "orjson-3.10.14-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:175abf3d20e737fec47261d278f95031736a49d7832a09ab684026528c4d96db"}, - {file = "orjson-3.10.14-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29ca1a93e035d570e8b791b6c0feddd403c6a5388bfe870bf2aa6bba1b9d9b8e"}, - {file = "orjson-3.10.14-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f77202c80e8ab5a1d1e9faf642343bee5aaf332061e1ada4e9147dbd9eb00c46"}, - {file = "orjson-3.10.14-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6e2ec73b7099b6a29b40a62e08a23b936423bd35529f8f55c42e27acccde7954"}, - {file = "orjson-3.10.14-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2d1679df9f9cd9504f8dff24555c1eaabba8aad7f5914f28dab99e3c2552c9d"}, - {file = "orjson-3.10.14-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:691ab9a13834310a263664313e4f747ceb93662d14a8bdf20eb97d27ed488f16"}, - {file = "orjson-3.10.14-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:b11ed82054fce82fb74cea33247d825d05ad6a4015ecfc02af5fbce442fbf361"}, - {file = "orjson-3.10.14-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:e70a1d62b8288677d48f3bea66c21586a5f999c64ecd3878edb7393e8d1b548d"}, - {file = "orjson-3.10.14-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:16642f10c1ca5611251bd835de9914a4b03095e28a34c8ba6a5500b5074338bd"}, - {file = "orjson-3.10.14-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:3871bad546aa66c155e3f36f99c459780c2a392d502a64e23fb96d9abf338511"}, - {file = "orjson-3.10.14-cp38-cp38-win32.whl", hash = "sha256:0293a88815e9bb5c90af4045f81ed364d982f955d12052d989d844d6c4e50945"}, - {file = "orjson-3.10.14-cp38-cp38-win_amd64.whl", hash = "sha256:6169d3868b190d6b21adc8e61f64e3db30f50559dfbdef34a1cd6c738d409dfc"}, - {file = "orjson-3.10.14-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:06d4ec218b1ec1467d8d64da4e123b4794c781b536203c309ca0f52819a16c03"}, - {file = "orjson-3.10.14-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:962c2ec0dcaf22b76dee9831fdf0c4a33d4bf9a257a2bc5d4adc00d5c8ad9034"}, - {file = "orjson-3.10.14-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:21d3be4132f71ef1360385770474f29ea1538a242eef72ac4934fe142800e37f"}, - {file = "orjson-3.10.14-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c28ed60597c149a9e3f5ad6dd9cebaee6fb2f0e3f2d159a4a2b9b862d4748860"}, - {file = "orjson-3.10.14-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e947f70167fe18469f2023644e91ab3d24f9aed69a5e1c78e2c81b9cea553fb"}, - {file = "orjson-3.10.14-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64410696c97a35af2432dea7bdc4ce32416458159430ef1b4beb79fd30093ad6"}, - {file = "orjson-3.10.14-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8050a5d81c022561ee29cd2739de5b4445f3c72f39423fde80a63299c1892c52"}, - {file = "orjson-3.10.14-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:b49a28e30d3eca86db3fe6f9b7f4152fcacbb4a467953cd1b42b94b479b77956"}, - {file = "orjson-3.10.14-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ca041ad20291a65d853a9523744eebc3f5a4b2f7634e99f8fe88320695ddf766"}, - {file = "orjson-3.10.14-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:d313a2998b74bb26e9e371851a173a9b9474764916f1fc7971095699b3c6e964"}, - {file = "orjson-3.10.14-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7796692136a67b3e301ef9052bde6fe8e7bd5200da766811a3a608ffa62aaff0"}, - {file = "orjson-3.10.14-cp39-cp39-win32.whl", hash = "sha256:eee4bc767f348fba485ed9dc576ca58b0a9eac237f0e160f7a59bce628ed06b3"}, - {file = "orjson-3.10.14-cp39-cp39-win_amd64.whl", hash = "sha256:96a1c0ee30fb113b3ae3c748fd75ca74a157ff4c58476c47db4d61518962a011"}, - {file = "orjson-3.10.14.tar.gz", hash = "sha256:cf31f6f071a6b8e7aa1ead1fa27b935b48d00fbfa6a28ce856cfff2d5dd68eed"}, -] - -[[package]] -name = "packaging" -version = "23.2" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.7" -files = [ - {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, - {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, -] - -[[package]] -name = "pathspec" -version = "0.12.1" -description = "Utility library for gitignore style pattern matching of file paths." -optional = false -python-versions = ">=3.8" -files = [ - {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, - {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, -] - -[[package]] -name = "pillow" -version = "10.4.0" -description = "Python Imaging Library (Fork)" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e"}, - {file = "pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d"}, - {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856"}, - {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f"}, - {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b"}, - {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc"}, - {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e"}, - {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46"}, - {file = "pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984"}, - {file = "pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141"}, - {file = "pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1"}, - {file = "pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c"}, - {file = "pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be"}, - {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3"}, - {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6"}, - {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe"}, - {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319"}, - {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d"}, - {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696"}, - {file = "pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496"}, - {file = "pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91"}, - {file = "pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22"}, - {file = "pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94"}, - {file = "pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597"}, - {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80"}, - {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca"}, - {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef"}, - {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a"}, - {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b"}, - {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9"}, - {file = "pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42"}, - {file = "pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a"}, - {file = "pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9"}, - {file = "pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3"}, - {file = "pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb"}, - {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70"}, - {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be"}, - {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0"}, - {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc"}, - {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a"}, - {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309"}, - {file = "pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060"}, - {file = "pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea"}, - {file = "pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d"}, - {file = "pillow-10.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8d4d5063501b6dd4024b8ac2f04962d661222d120381272deea52e3fc52d3736"}, - {file = "pillow-10.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7c1ee6f42250df403c5f103cbd2768a28fe1a0ea1f0f03fe151c8741e1469c8b"}, - {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15e02e9bb4c21e39876698abf233c8c579127986f8207200bc8a8f6bb27acf2"}, - {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a8d4bade9952ea9a77d0c3e49cbd8b2890a399422258a77f357b9cc9be8d680"}, - {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:43efea75eb06b95d1631cb784aa40156177bf9dd5b4b03ff38979e048258bc6b"}, - {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:950be4d8ba92aca4b2bb0741285a46bfae3ca699ef913ec8416c1b78eadd64cd"}, - {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d7480af14364494365e89d6fddc510a13e5a2c3584cb19ef65415ca57252fb84"}, - {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:73664fe514b34c8f02452ffb73b7a92c6774e39a647087f83d67f010eb9a0cf0"}, - {file = "pillow-10.4.0-cp38-cp38-win32.whl", hash = "sha256:e88d5e6ad0d026fba7bdab8c3f225a69f063f116462c49892b0149e21b6c0a0e"}, - {file = "pillow-10.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:5161eef006d335e46895297f642341111945e2c1c899eb406882a6c61a4357ab"}, - {file = "pillow-10.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0ae24a547e8b711ccaaf99c9ae3cd975470e1a30caa80a6aaee9a2f19c05701d"}, - {file = "pillow-10.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:298478fe4f77a4408895605f3482b6cc6222c018b2ce565c2b6b9c354ac3229b"}, - {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:134ace6dc392116566980ee7436477d844520a26a4b1bd4053f6f47d096997fd"}, - {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:930044bb7679ab003b14023138b50181899da3f25de50e9dbee23b61b4de2126"}, - {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:c76e5786951e72ed3686e122d14c5d7012f16c8303a674d18cdcd6d89557fc5b"}, - {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b2724fdb354a868ddf9a880cb84d102da914e99119211ef7ecbdc613b8c96b3c"}, - {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dbc6ae66518ab3c5847659e9988c3b60dc94ffb48ef9168656e0019a93dbf8a1"}, - {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:06b2f7898047ae93fad74467ec3d28fe84f7831370e3c258afa533f81ef7f3df"}, - {file = "pillow-10.4.0-cp39-cp39-win32.whl", hash = "sha256:7970285ab628a3779aecc35823296a7869f889b8329c16ad5a71e4901a3dc4ef"}, - {file = "pillow-10.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:961a7293b2457b405967af9c77dcaa43cc1a8cd50d23c532e62d48ab6cdd56f5"}, - {file = "pillow-10.4.0-cp39-cp39-win_arm64.whl", hash = "sha256:32cda9e3d601a52baccb2856b8ea1fc213c90b340c542dcef77140dfa3278a9e"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a02364621fe369e06200d4a16558e056fe2805d3468350df3aef21e00d26214b"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1b5dea9831a90e9d0721ec417a80d4cbd7022093ac38a568db2dd78363b00908"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b885f89040bb8c4a1573566bbb2f44f5c505ef6e74cec7ab9068c900047f04b"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87dd88ded2e6d74d31e1e0a99a726a6765cda32d00ba72dc37f0651f306daaa8"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:2db98790afc70118bd0255c2eeb465e9767ecf1f3c25f9a1abb8ffc8cfd1fe0a"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f7baece4ce06bade126fb84b8af1c33439a76d8a6fd818970215e0560ca28c27"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cfdd747216947628af7b259d274771d84db2268ca062dd5faf373639d00113a3"}, - {file = "pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06"}, -] - -[package.extras] -docs = ["furo", "olefile", "sphinx (>=7.3)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] -fpx = ["olefile"] -mic = ["olefile"] -tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] -typing = ["typing-extensions"] -xmp = ["defusedxml"] - -[[package]] -name = "platformdirs" -version = "4.1.0" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -optional = false -python-versions = ">=3.8" -files = [ - {file = "platformdirs-4.1.0-py3-none-any.whl", hash = "sha256:11c8f37bcca40db96d8144522d925583bdb7a31f7b0e37e3ed4318400a8e2380"}, - {file = "platformdirs-4.1.0.tar.gz", hash = "sha256:906d548203468492d432bcb294d4bc2fff751bf84971fbb2c10918cc206ee420"}, -] - -[package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] - -[[package]] -name = "pluggy" -version = "1.5.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, - {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] - -[[package]] -name = "prettytable" -version = "3.9.0" -description = "A simple Python library for easily displaying tabular data in a visually appealing ASCII table format" -optional = false -python-versions = ">=3.8" -files = [ - {file = "prettytable-3.9.0-py3-none-any.whl", hash = "sha256:a71292ab7769a5de274b146b276ce938786f56c31cf7cea88b6f3775d82fe8c8"}, - {file = "prettytable-3.9.0.tar.gz", hash = "sha256:f4ed94803c23073a90620b201965e5dc0bccf1760b7a7eaf3158cab8aaffdf34"}, -] - -[package.dependencies] -wcwidth = "*" - -[package.extras] -tests = ["pytest", "pytest-cov", "pytest-lazy-fixture"] - -[[package]] -name = "protobuf" -version = "4.25.2" -description = "" -optional = false -python-versions = ">=3.8" -files = [ - {file = "protobuf-4.25.2-cp310-abi3-win32.whl", hash = "sha256:b50c949608682b12efb0b2717f53256f03636af5f60ac0c1d900df6213910fd6"}, - {file = "protobuf-4.25.2-cp310-abi3-win_amd64.whl", hash = "sha256:8f62574857ee1de9f770baf04dde4165e30b15ad97ba03ceac65f760ff018ac9"}, - {file = "protobuf-4.25.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:2db9f8fa64fbdcdc93767d3cf81e0f2aef176284071507e3ede160811502fd3d"}, - {file = "protobuf-4.25.2-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:10894a2885b7175d3984f2be8d9850712c57d5e7587a2410720af8be56cdaf62"}, - {file = "protobuf-4.25.2-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:fc381d1dd0516343f1440019cedf08a7405f791cd49eef4ae1ea06520bc1c020"}, - {file = "protobuf-4.25.2-cp38-cp38-win32.whl", hash = "sha256:33a1aeef4b1927431d1be780e87b641e322b88d654203a9e9d93f218ee359e61"}, - {file = "protobuf-4.25.2-cp38-cp38-win_amd64.whl", hash = "sha256:47f3de503fe7c1245f6f03bea7e8d3ec11c6c4a2ea9ef910e3221c8a15516d62"}, - {file = "protobuf-4.25.2-cp39-cp39-win32.whl", hash = "sha256:5e5c933b4c30a988b52e0b7c02641760a5ba046edc5e43d3b94a74c9fc57c1b3"}, - {file = "protobuf-4.25.2-cp39-cp39-win_amd64.whl", hash = "sha256:d66a769b8d687df9024f2985d5137a337f957a0916cf5464d1513eee96a63ff0"}, - {file = "protobuf-4.25.2-py3-none-any.whl", hash = "sha256:a8b7a98d4ce823303145bf3c1a8bdb0f2f4642a414b196f04ad9853ed0c8f830"}, - {file = "protobuf-4.25.2.tar.gz", hash = "sha256:fe599e175cb347efc8ee524bcd4b902d11f7262c0e569ececcb89995c15f0a5e"}, -] - -[[package]] -name = "psutil" -version = "5.9.7" -description = "Cross-platform lib for process and system monitoring in Python." -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" -files = [ - {file = "psutil-5.9.7-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:0bd41bf2d1463dfa535942b2a8f0e958acf6607ac0be52265ab31f7923bcd5e6"}, - {file = "psutil-5.9.7-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:5794944462509e49d4d458f4dbfb92c47539e7d8d15c796f141f474010084056"}, - {file = "psutil-5.9.7-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:fe361f743cb3389b8efda21980d93eb55c1f1e3898269bc9a2a1d0bb7b1f6508"}, - {file = "psutil-5.9.7-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:e469990e28f1ad738f65a42dcfc17adaed9d0f325d55047593cb9033a0ab63df"}, - {file = "psutil-5.9.7-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:3c4747a3e2ead1589e647e64aad601981f01b68f9398ddf94d01e3dc0d1e57c7"}, - {file = "psutil-5.9.7-cp27-none-win32.whl", hash = "sha256:1d4bc4a0148fdd7fd8f38e0498639ae128e64538faa507df25a20f8f7fb2341c"}, - {file = "psutil-5.9.7-cp27-none-win_amd64.whl", hash = "sha256:4c03362e280d06bbbfcd52f29acd79c733e0af33d707c54255d21029b8b32ba6"}, - {file = "psutil-5.9.7-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ea36cc62e69a13ec52b2f625c27527f6e4479bca2b340b7a452af55b34fcbe2e"}, - {file = "psutil-5.9.7-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1132704b876e58d277168cd729d64750633d5ff0183acf5b3c986b8466cd0284"}, - {file = "psutil-5.9.7-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe8b7f07948f1304497ce4f4684881250cd859b16d06a1dc4d7941eeb6233bfe"}, - {file = "psutil-5.9.7-cp36-cp36m-win32.whl", hash = "sha256:b27f8fdb190c8c03914f908a4555159327d7481dac2f01008d483137ef3311a9"}, - {file = "psutil-5.9.7-cp36-cp36m-win_amd64.whl", hash = "sha256:44969859757f4d8f2a9bd5b76eba8c3099a2c8cf3992ff62144061e39ba8568e"}, - {file = "psutil-5.9.7-cp37-abi3-win32.whl", hash = "sha256:c727ca5a9b2dd5193b8644b9f0c883d54f1248310023b5ad3e92036c5e2ada68"}, - {file = "psutil-5.9.7-cp37-abi3-win_amd64.whl", hash = "sha256:f37f87e4d73b79e6c5e749440c3113b81d1ee7d26f21c19c47371ddea834f414"}, - {file = "psutil-5.9.7-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:032f4f2c909818c86cea4fe2cc407f1c0f0cde8e6c6d702b28b8ce0c0d143340"}, - {file = "psutil-5.9.7.tar.gz", hash = "sha256:3f02134e82cfb5d089fddf20bb2e03fd5cd52395321d1c8458a9e58500ff417c"}, -] - -[package.extras] -test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"] - -[[package]] -name = "pycparser" -version = "2.21" -description = "C parser in Python" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, - {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, -] - -[[package]] -name = "pydantic" -version = "2.10.5" -description = "Data validation using Python type hints" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pydantic-2.10.5-py3-none-any.whl", hash = "sha256:4dd4e322dbe55472cb7ca7e73f4b63574eecccf2835ffa2af9021ce113c83c53"}, - {file = "pydantic-2.10.5.tar.gz", hash = "sha256:278b38dbbaec562011d659ee05f63346951b3a248a6f3642e1bc68894ea2b4ff"}, -] - -[package.dependencies] -annotated-types = ">=0.6.0" -pydantic-core = "2.27.2" -typing-extensions = ">=4.12.2" - -[package.extras] -email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata"] - -[[package]] -name = "pydantic-core" -version = "2.27.2" -description = "Core functionality for Pydantic validation and serialization" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa"}, - {file = "pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c"}, - {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a"}, - {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5"}, - {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c"}, - {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7"}, - {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a"}, - {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236"}, - {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962"}, - {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9"}, - {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af"}, - {file = "pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4"}, - {file = "pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31"}, - {file = "pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc"}, - {file = "pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7"}, - {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15"}, - {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306"}, - {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99"}, - {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459"}, - {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048"}, - {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d"}, - {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b"}, - {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474"}, - {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6"}, - {file = "pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c"}, - {file = "pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc"}, - {file = "pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4"}, - {file = "pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0"}, - {file = "pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef"}, - {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7"}, - {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934"}, - {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6"}, - {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c"}, - {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2"}, - {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4"}, - {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3"}, - {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4"}, - {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57"}, - {file = "pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc"}, - {file = "pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9"}, - {file = "pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b"}, - {file = "pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b"}, - {file = "pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154"}, - {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9"}, - {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9"}, - {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1"}, - {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a"}, - {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e"}, - {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4"}, - {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27"}, - {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee"}, - {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1"}, - {file = "pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130"}, - {file = "pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee"}, - {file = "pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b"}, - {file = "pydantic_core-2.27.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d3e8d504bdd3f10835468f29008d72fc8359d95c9c415ce6e767203db6127506"}, - {file = "pydantic_core-2.27.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:521eb9b7f036c9b6187f0b47318ab0d7ca14bd87f776240b90b21c1f4f149320"}, - {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85210c4d99a0114f5a9481b44560d7d1e35e32cc5634c656bc48e590b669b145"}, - {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d716e2e30c6f140d7560ef1538953a5cd1a87264c737643d481f2779fc247fe1"}, - {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f66d89ba397d92f840f8654756196d93804278457b5fbede59598a1f9f90b228"}, - {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:669e193c1c576a58f132e3158f9dfa9662969edb1a250c54d8fa52590045f046"}, - {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdbe7629b996647b99c01b37f11170a57ae675375b14b8c13b8518b8320ced5"}, - {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d262606bf386a5ba0b0af3b97f37c83d7011439e3dc1a9298f21efb292e42f1a"}, - {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:cabb9bcb7e0d97f74df8646f34fc76fbf793b7f6dc2438517d7a9e50eee4f14d"}, - {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:d2d63f1215638d28221f664596b1ccb3944f6e25dd18cd3b86b0a4c408d5ebb9"}, - {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bca101c00bff0adb45a833f8451b9105d9df18accb8743b08107d7ada14bd7da"}, - {file = "pydantic_core-2.27.2-cp38-cp38-win32.whl", hash = "sha256:f6f8e111843bbb0dee4cb6594cdc73e79b3329b526037ec242a3e49012495b3b"}, - {file = "pydantic_core-2.27.2-cp38-cp38-win_amd64.whl", hash = "sha256:fd1aea04935a508f62e0d0ef1f5ae968774a32afc306fb8545e06f5ff5cdf3ad"}, - {file = "pydantic_core-2.27.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993"}, - {file = "pydantic_core-2.27.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308"}, - {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4"}, - {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf"}, - {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76"}, - {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118"}, - {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630"}, - {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54"}, - {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f"}, - {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362"}, - {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96"}, - {file = "pydantic_core-2.27.2-cp39-cp39-win32.whl", hash = "sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e"}, - {file = "pydantic_core-2.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67"}, - {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e"}, - {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8"}, - {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3"}, - {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f"}, - {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133"}, - {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc"}, - {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50"}, - {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9"}, - {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151"}, - {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656"}, - {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278"}, - {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb"}, - {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd"}, - {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc"}, - {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b"}, - {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b"}, - {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2"}, - {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35"}, - {file = "pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39"}, -] - -[package.dependencies] -typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" - -[[package]] -name = "pydantic-settings" -version = "2.7.1" -description = "Settings management using Pydantic" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pydantic_settings-2.7.1-py3-none-any.whl", hash = "sha256:590be9e6e24d06db33a4262829edef682500ef008565a969c73d39d5f8bfb3fd"}, - {file = "pydantic_settings-2.7.1.tar.gz", hash = "sha256:10c9caad35e64bfb3c2fbf70a078c0e25cc92499782e5200747f942a065dec93"}, -] - -[package.dependencies] -pydantic = ">=2.7.0" -python-dotenv = ">=0.21.0" - -[package.extras] -azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] -toml = ["tomli (>=2.0.1)"] -yaml = ["pyyaml (>=6.0.1)"] - -[[package]] -name = "pygments" -version = "2.17.2" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.7" -files = [ - {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"}, - {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"}, -] - -[package.extras] -plugins = ["importlib-metadata"] -windows-terminal = ["colorama (>=0.4.6)"] - -[[package]] -name = "pyparsing" -version = "3.1.1" -description = "pyparsing module - Classes and methods to define and execute parsing grammars" -optional = false -python-versions = ">=3.6.8" -files = [ - {file = "pyparsing-3.1.1-py3-none-any.whl", hash = "sha256:32c7c0b711493c72ff18a981d24f28aaf9c1fb7ed5e9667c9e84e3db623bdbfb"}, - {file = "pyparsing-3.1.1.tar.gz", hash = "sha256:ede28a1a32462f5a9705e07aea48001a08f7cf81a021585011deba701581a0db"}, -] - -[package.extras] -diagrams = ["jinja2", "railroad-diagrams"] - -[[package]] -name = "pyreadline3" -version = "3.4.1" -description = "A python implementation of GNU readline." -optional = false -python-versions = "*" -files = [ - {file = "pyreadline3-3.4.1-py3-none-any.whl", hash = "sha256:b0efb6516fd4fb07b45949053826a62fa4cb353db5be2bbb4a7aa1fdd1e345fb"}, - {file = "pyreadline3-3.4.1.tar.gz", hash = "sha256:6f3d1f7b8a31ba32b73917cefc1f28cc660562f39aea8646d30bd6eff21f7bae"}, -] - -[[package]] -name = "pytest" -version = "8.3.4" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, - {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=1.5,<2" -tomli = {version = ">=1", markers = "python_version < \"3.11\""} - -[package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "pytest-asyncio" -version = "0.25.2" -description = "Pytest support for asyncio" -optional = false -python-versions = ">=3.9" -files = [ - {file = "pytest_asyncio-0.25.2-py3-none-any.whl", hash = "sha256:0d0bb693f7b99da304a0634afc0a4b19e49d5e0de2d670f38dc4bfa5727c5075"}, - {file = "pytest_asyncio-0.25.2.tar.gz", hash = "sha256:3f8ef9a98f45948ea91a0ed3dc4268b5326c0e7bce73892acc654df4262ad45f"}, -] - -[package.dependencies] -pytest = ">=8.2,<9" - -[package.extras] -docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] -testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] - -[[package]] -name = "pytest-cov" -version = "6.0.0" -description = "Pytest plugin for measuring coverage." -optional = false -python-versions = ">=3.9" -files = [ - {file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"}, - {file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"}, -] - -[package.dependencies] -coverage = {version = ">=7.5", extras = ["toml"]} -pytest = ">=4.6" - -[package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] - -[[package]] -name = "pytest-mock" -version = "3.14.0" -description = "Thin-wrapper around the mock package for easier use with pytest" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, - {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, -] - -[package.dependencies] -pytest = ">=6.2.5" - -[package.extras] -dev = ["pre-commit", "pytest-asyncio", "tox"] - -[[package]] -name = "python-dateutil" -version = "2.8.2" -description = "Extensions to the standard Python datetime module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -files = [ - {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, - {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, -] - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "python-dotenv" -version = "1.0.0" -description = "Read key-value pairs from a .env file and set them as environment variables" -optional = false -python-versions = ">=3.8" -files = [ - {file = "python-dotenv-1.0.0.tar.gz", hash = "sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba"}, - {file = "python_dotenv-1.0.0-py3-none-any.whl", hash = "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a"}, -] - -[package.extras] -cli = ["click (>=5.0)"] - -[[package]] -name = "python-multipart" -version = "0.0.20" -description = "A streaming multipart parser for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104"}, - {file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"}, -] - -[[package]] -name = "pywin32" -version = "306" -description = "Python for Window Extensions" -optional = false -python-versions = "*" -files = [ - {file = "pywin32-306-cp310-cp310-win32.whl", hash = "sha256:06d3420a5155ba65f0b72f2699b5bacf3109f36acbe8923765c22938a69dfc8d"}, - {file = "pywin32-306-cp310-cp310-win_amd64.whl", hash = "sha256:84f4471dbca1887ea3803d8848a1616429ac94a4a8d05f4bc9c5dcfd42ca99c8"}, - {file = "pywin32-306-cp311-cp311-win32.whl", hash = "sha256:e65028133d15b64d2ed8f06dd9fbc268352478d4f9289e69c190ecd6818b6407"}, - {file = "pywin32-306-cp311-cp311-win_amd64.whl", hash = "sha256:a7639f51c184c0272e93f244eb24dafca9b1855707d94c192d4a0b4c01e1100e"}, - {file = "pywin32-306-cp311-cp311-win_arm64.whl", hash = "sha256:70dba0c913d19f942a2db25217d9a1b726c278f483a919f1abfed79c9cf64d3a"}, - {file = "pywin32-306-cp312-cp312-win32.whl", hash = "sha256:383229d515657f4e3ed1343da8be101000562bf514591ff383ae940cad65458b"}, - {file = "pywin32-306-cp312-cp312-win_amd64.whl", hash = "sha256:37257794c1ad39ee9be652da0462dc2e394c8159dfd913a8a4e8eb6fd346da0e"}, - {file = "pywin32-306-cp312-cp312-win_arm64.whl", hash = "sha256:5821ec52f6d321aa59e2db7e0a35b997de60c201943557d108af9d4ae1ec7040"}, - {file = "pywin32-306-cp37-cp37m-win32.whl", hash = "sha256:1c73ea9a0d2283d889001998059f5eaaba3b6238f767c9cf2833b13e6a685f65"}, - {file = "pywin32-306-cp37-cp37m-win_amd64.whl", hash = "sha256:72c5f621542d7bdd4fdb716227be0dd3f8565c11b280be6315b06ace35487d36"}, - {file = "pywin32-306-cp38-cp38-win32.whl", hash = "sha256:e4c092e2589b5cf0d365849e73e02c391c1349958c5ac3e9d5ccb9a28e017b3a"}, - {file = "pywin32-306-cp38-cp38-win_amd64.whl", hash = "sha256:e8ac1ae3601bee6ca9f7cb4b5363bf1c0badb935ef243c4733ff9a393b1690c0"}, - {file = "pywin32-306-cp39-cp39-win32.whl", hash = "sha256:e25fd5b485b55ac9c057f67d94bc203f3f6595078d1fb3b458c9c28b7153a802"}, - {file = "pywin32-306-cp39-cp39-win_amd64.whl", hash = "sha256:39b61c15272833b5c329a2989999dcae836b1eed650252ab1b7bfbe1d59f30f4"}, -] - -[[package]] -name = "pyyaml" -version = "6.0.1" -description = "YAML parser and emitter for Python" -optional = false -python-versions = ">=3.6" -files = [ - {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, - {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, - {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, - {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, - {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, - {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, - {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, - {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, - {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, - {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, - {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, - {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, - {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, - {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, -] - -[[package]] -name = "pyzmq" -version = "25.1.2" -description = "Python bindings for 0MQ" -optional = false -python-versions = ">=3.6" -files = [ - {file = "pyzmq-25.1.2-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:e624c789359f1a16f83f35e2c705d07663ff2b4d4479bad35621178d8f0f6ea4"}, - {file = "pyzmq-25.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:49151b0efece79f6a79d41a461d78535356136ee70084a1c22532fc6383f4ad0"}, - {file = "pyzmq-25.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9a5f194cf730f2b24d6af1f833c14c10f41023da46a7f736f48b6d35061e76e"}, - {file = "pyzmq-25.1.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:faf79a302f834d9e8304fafdc11d0d042266667ac45209afa57e5efc998e3872"}, - {file = "pyzmq-25.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f51a7b4ead28d3fca8dda53216314a553b0f7a91ee8fc46a72b402a78c3e43d"}, - {file = "pyzmq-25.1.2-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:0ddd6d71d4ef17ba5a87becf7ddf01b371eaba553c603477679ae817a8d84d75"}, - {file = "pyzmq-25.1.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:246747b88917e4867e2367b005fc8eefbb4a54b7db363d6c92f89d69abfff4b6"}, - {file = "pyzmq-25.1.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:00c48ae2fd81e2a50c3485de1b9d5c7c57cd85dc8ec55683eac16846e57ac979"}, - {file = "pyzmq-25.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5a68d491fc20762b630e5db2191dd07ff89834086740f70e978bb2ef2668be08"}, - {file = "pyzmq-25.1.2-cp310-cp310-win32.whl", hash = "sha256:09dfe949e83087da88c4a76767df04b22304a682d6154de2c572625c62ad6886"}, - {file = "pyzmq-25.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:fa99973d2ed20417744fca0073390ad65ce225b546febb0580358e36aa90dba6"}, - {file = "pyzmq-25.1.2-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:82544e0e2d0c1811482d37eef297020a040c32e0687c1f6fc23a75b75db8062c"}, - {file = "pyzmq-25.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:01171fc48542348cd1a360a4b6c3e7d8f46cdcf53a8d40f84db6707a6768acc1"}, - {file = "pyzmq-25.1.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc69c96735ab501419c432110016329bf0dea8898ce16fab97c6d9106dc0b348"}, - {file = "pyzmq-25.1.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3e124e6b1dd3dfbeb695435dff0e383256655bb18082e094a8dd1f6293114642"}, - {file = "pyzmq-25.1.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7598d2ba821caa37a0f9d54c25164a4fa351ce019d64d0b44b45540950458840"}, - {file = "pyzmq-25.1.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d1299d7e964c13607efd148ca1f07dcbf27c3ab9e125d1d0ae1d580a1682399d"}, - {file = "pyzmq-25.1.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4e6f689880d5ad87918430957297c975203a082d9a036cc426648fcbedae769b"}, - {file = "pyzmq-25.1.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cc69949484171cc961e6ecd4a8911b9ce7a0d1f738fcae717177c231bf77437b"}, - {file = "pyzmq-25.1.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9880078f683466b7f567b8624bfc16cad65077be046b6e8abb53bed4eeb82dd3"}, - {file = "pyzmq-25.1.2-cp311-cp311-win32.whl", hash = "sha256:4e5837af3e5aaa99a091302df5ee001149baff06ad22b722d34e30df5f0d9097"}, - {file = "pyzmq-25.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:25c2dbb97d38b5ac9fd15586e048ec5eb1e38f3d47fe7d92167b0c77bb3584e9"}, - {file = "pyzmq-25.1.2-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:11e70516688190e9c2db14fcf93c04192b02d457b582a1f6190b154691b4c93a"}, - {file = "pyzmq-25.1.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:313c3794d650d1fccaaab2df942af9f2c01d6217c846177cfcbc693c7410839e"}, - {file = "pyzmq-25.1.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b3cbba2f47062b85fe0ef9de5b987612140a9ba3a9c6d2543c6dec9f7c2ab27"}, - {file = "pyzmq-25.1.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fc31baa0c32a2ca660784d5af3b9487e13b61b3032cb01a115fce6588e1bed30"}, - {file = "pyzmq-25.1.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02c9087b109070c5ab0b383079fa1b5f797f8d43e9a66c07a4b8b8bdecfd88ee"}, - {file = "pyzmq-25.1.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:f8429b17cbb746c3e043cb986328da023657e79d5ed258b711c06a70c2ea7537"}, - {file = "pyzmq-25.1.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5074adeacede5f810b7ef39607ee59d94e948b4fd954495bdb072f8c54558181"}, - {file = "pyzmq-25.1.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:7ae8f354b895cbd85212da245f1a5ad8159e7840e37d78b476bb4f4c3f32a9fe"}, - {file = "pyzmq-25.1.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b264bf2cc96b5bc43ce0e852be995e400376bd87ceb363822e2cb1964fcdc737"}, - {file = "pyzmq-25.1.2-cp312-cp312-win32.whl", hash = "sha256:02bbc1a87b76e04fd780b45e7f695471ae6de747769e540da909173d50ff8e2d"}, - {file = "pyzmq-25.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:ced111c2e81506abd1dc142e6cd7b68dd53747b3b7ae5edbea4578c5eeff96b7"}, - {file = "pyzmq-25.1.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:7b6d09a8962a91151f0976008eb7b29b433a560fde056ec7a3db9ec8f1075438"}, - {file = "pyzmq-25.1.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:967668420f36878a3c9ecb5ab33c9d0ff8d054f9c0233d995a6d25b0e95e1b6b"}, - {file = "pyzmq-25.1.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5edac3f57c7ddaacdb4d40f6ef2f9e299471fc38d112f4bc6d60ab9365445fb0"}, - {file = "pyzmq-25.1.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:0dabfb10ef897f3b7e101cacba1437bd3a5032ee667b7ead32bbcdd1a8422fe7"}, - {file = "pyzmq-25.1.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:2c6441e0398c2baacfe5ba30c937d274cfc2dc5b55e82e3749e333aabffde561"}, - {file = "pyzmq-25.1.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:16b726c1f6c2e7625706549f9dbe9b06004dfbec30dbed4bf50cbdfc73e5b32a"}, - {file = "pyzmq-25.1.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:a86c2dd76ef71a773e70551a07318b8e52379f58dafa7ae1e0a4be78efd1ff16"}, - {file = "pyzmq-25.1.2-cp36-cp36m-win32.whl", hash = "sha256:359f7f74b5d3c65dae137f33eb2bcfa7ad9ebefd1cab85c935f063f1dbb245cc"}, - {file = "pyzmq-25.1.2-cp36-cp36m-win_amd64.whl", hash = "sha256:55875492f820d0eb3417b51d96fea549cde77893ae3790fd25491c5754ea2f68"}, - {file = "pyzmq-25.1.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b8c8a419dfb02e91b453615c69568442e897aaf77561ee0064d789705ff37a92"}, - {file = "pyzmq-25.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8807c87fa893527ae8a524c15fc505d9950d5e856f03dae5921b5e9aa3b8783b"}, - {file = "pyzmq-25.1.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5e319ed7d6b8f5fad9b76daa0a68497bc6f129858ad956331a5835785761e003"}, - {file = "pyzmq-25.1.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:3c53687dde4d9d473c587ae80cc328e5b102b517447456184b485587ebd18b62"}, - {file = "pyzmq-25.1.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:9add2e5b33d2cd765ad96d5eb734a5e795a0755f7fc49aa04f76d7ddda73fd70"}, - {file = "pyzmq-25.1.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:e690145a8c0c273c28d3b89d6fb32c45e0d9605b2293c10e650265bf5c11cfec"}, - {file = "pyzmq-25.1.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:00a06faa7165634f0cac1abb27e54d7a0b3b44eb9994530b8ec73cf52e15353b"}, - {file = "pyzmq-25.1.2-cp37-cp37m-win32.whl", hash = "sha256:0f97bc2f1f13cb16905a5f3e1fbdf100e712d841482b2237484360f8bc4cb3d7"}, - {file = "pyzmq-25.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6cc0020b74b2e410287e5942e1e10886ff81ac77789eb20bec13f7ae681f0fdd"}, - {file = "pyzmq-25.1.2-cp38-cp38-macosx_10_15_universal2.whl", hash = "sha256:bef02cfcbded83473bdd86dd8d3729cd82b2e569b75844fb4ea08fee3c26ae41"}, - {file = "pyzmq-25.1.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e10a4b5a4b1192d74853cc71a5e9fd022594573926c2a3a4802020360aa719d8"}, - {file = "pyzmq-25.1.2-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:8c5f80e578427d4695adac6fdf4370c14a2feafdc8cb35549c219b90652536ae"}, - {file = "pyzmq-25.1.2-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5dde6751e857910c1339890f3524de74007958557593b9e7e8c5f01cd919f8a7"}, - {file = "pyzmq-25.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea1608dd169da230a0ad602d5b1ebd39807ac96cae1845c3ceed39af08a5c6df"}, - {file = "pyzmq-25.1.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0f513130c4c361201da9bc69df25a086487250e16b5571ead521b31ff6b02220"}, - {file = "pyzmq-25.1.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:019744b99da30330798bb37df33549d59d380c78e516e3bab9c9b84f87a9592f"}, - {file = "pyzmq-25.1.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2e2713ef44be5d52dd8b8e2023d706bf66cb22072e97fc71b168e01d25192755"}, - {file = "pyzmq-25.1.2-cp38-cp38-win32.whl", hash = "sha256:07cd61a20a535524906595e09344505a9bd46f1da7a07e504b315d41cd42eb07"}, - {file = "pyzmq-25.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb7e49a17fb8c77d3119d41a4523e432eb0c6932187c37deb6fbb00cc3028088"}, - {file = "pyzmq-25.1.2-cp39-cp39-macosx_10_15_universal2.whl", hash = "sha256:94504ff66f278ab4b7e03e4cba7e7e400cb73bfa9d3d71f58d8972a8dc67e7a6"}, - {file = "pyzmq-25.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6dd0d50bbf9dca1d0bdea219ae6b40f713a3fb477c06ca3714f208fd69e16fd8"}, - {file = "pyzmq-25.1.2-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:004ff469d21e86f0ef0369717351073e0e577428e514c47c8480770d5e24a565"}, - {file = "pyzmq-25.1.2-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c0b5ca88a8928147b7b1e2dfa09f3b6c256bc1135a1338536cbc9ea13d3b7add"}, - {file = "pyzmq-25.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c9a79f1d2495b167119d02be7448bfba57fad2a4207c4f68abc0bab4b92925b"}, - {file = "pyzmq-25.1.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:518efd91c3d8ac9f9b4f7dd0e2b7b8bf1a4fe82a308009016b07eaa48681af82"}, - {file = "pyzmq-25.1.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:1ec23bd7b3a893ae676d0e54ad47d18064e6c5ae1fadc2f195143fb27373f7f6"}, - {file = "pyzmq-25.1.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:db36c27baed588a5a8346b971477b718fdc66cf5b80cbfbd914b4d6d355e44e2"}, - {file = "pyzmq-25.1.2-cp39-cp39-win32.whl", hash = "sha256:39b1067f13aba39d794a24761e385e2eddc26295826530a8c7b6c6c341584289"}, - {file = "pyzmq-25.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:8e9f3fabc445d0ce320ea2c59a75fe3ea591fdbdeebec5db6de530dd4b09412e"}, - {file = "pyzmq-25.1.2-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a8c1d566344aee826b74e472e16edae0a02e2a044f14f7c24e123002dcff1c05"}, - {file = "pyzmq-25.1.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:759cfd391a0996345ba94b6a5110fca9c557ad4166d86a6e81ea526c376a01e8"}, - {file = "pyzmq-25.1.2-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c61e346ac34b74028ede1c6b4bcecf649d69b707b3ff9dc0fab453821b04d1e"}, - {file = "pyzmq-25.1.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4cb8fc1f8d69b411b8ec0b5f1ffbcaf14c1db95b6bccea21d83610987435f1a4"}, - {file = "pyzmq-25.1.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:3c00c9b7d1ca8165c610437ca0c92e7b5607b2f9076f4eb4b095c85d6e680a1d"}, - {file = "pyzmq-25.1.2-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:df0c7a16ebb94452d2909b9a7b3337940e9a87a824c4fc1c7c36bb4404cb0cde"}, - {file = "pyzmq-25.1.2-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:45999e7f7ed5c390f2e87ece7f6c56bf979fb213550229e711e45ecc7d42ccb8"}, - {file = "pyzmq-25.1.2-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ac170e9e048b40c605358667aca3d94e98f604a18c44bdb4c102e67070f3ac9b"}, - {file = "pyzmq-25.1.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1b604734bec94f05f81b360a272fc824334267426ae9905ff32dc2be433ab96"}, - {file = "pyzmq-25.1.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:a793ac733e3d895d96f865f1806f160696422554e46d30105807fdc9841b9f7d"}, - {file = "pyzmq-25.1.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0806175f2ae5ad4b835ecd87f5f85583316b69f17e97786f7443baaf54b9bb98"}, - {file = "pyzmq-25.1.2-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ef12e259e7bc317c7597d4f6ef59b97b913e162d83b421dd0db3d6410f17a244"}, - {file = "pyzmq-25.1.2-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ea253b368eb41116011add00f8d5726762320b1bda892f744c91997b65754d73"}, - {file = "pyzmq-25.1.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b9b1f2ad6498445a941d9a4fee096d387fee436e45cc660e72e768d3d8ee611"}, - {file = "pyzmq-25.1.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:8b14c75979ce932c53b79976a395cb2a8cd3aaf14aef75e8c2cb55a330b9b49d"}, - {file = "pyzmq-25.1.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:889370d5174a741a62566c003ee8ddba4b04c3f09a97b8000092b7ca83ec9c49"}, - {file = "pyzmq-25.1.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9a18fff090441a40ffda8a7f4f18f03dc56ae73f148f1832e109f9bffa85df15"}, - {file = "pyzmq-25.1.2-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99a6b36f95c98839ad98f8c553d8507644c880cf1e0a57fe5e3a3f3969040882"}, - {file = "pyzmq-25.1.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4345c9a27f4310afbb9c01750e9461ff33d6fb74cd2456b107525bbeebcb5be3"}, - {file = "pyzmq-25.1.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3516e0b6224cf6e43e341d56da15fd33bdc37fa0c06af4f029f7d7dfceceabbc"}, - {file = "pyzmq-25.1.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:146b9b1f29ead41255387fb07be56dc29639262c0f7344f570eecdcd8d683314"}, - {file = "pyzmq-25.1.2.tar.gz", hash = "sha256:93f1aa311e8bb912e34f004cf186407a4e90eec4f0ecc0efd26056bf7eda0226"}, -] - -[package.dependencies] -cffi = {version = "*", markers = "implementation_name == \"pypy\""} - -[[package]] -name = "qudida" -version = "0.0.4" -description = "QUick and DIrty Domain Adaptation" -optional = false -python-versions = ">=3.5.0" -files = [ - {file = "qudida-0.0.4-py3-none-any.whl", hash = "sha256:4519714c40cd0f2e6c51e1735edae8f8b19f4efe1f33be13e9d644ca5f736dd6"}, - {file = "qudida-0.0.4.tar.gz", hash = "sha256:db198e2887ab0c9aa0023e565afbff41dfb76b361f85fd5e13f780d75ba18cc8"}, -] - -[package.dependencies] -numpy = ">=0.18.0" -opencv-python-headless = ">=4.0.1" -scikit-learn = ">=0.19.1" -typing-extensions = "*" - -[[package]] -name = "requests" -version = "2.32.3" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.8" -files = [ - {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, - {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, -] - -[package.dependencies] -certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] - -[[package]] -name = "rich" -version = "13.9.4" -description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" -optional = false -python-versions = ">=3.8.0" -files = [ - {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"}, - {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"}, -] - -[package.dependencies] -markdown-it-py = ">=2.2.0" -pygments = ">=2.13.0,<3.0.0" -typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.11\""} - -[package.extras] -jupyter = ["ipywidgets (>=7.5.1,<9)"] - -[[package]] -name = "rknn-toolkit-lite2" -version = "2.3.0" -description = "Rockchip Neural Network Toolkit Lite2. (commit: 27e2f01)" -optional = false -python-versions = "*" -files = [ - {file = "rknn_toolkit_lite2-2.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b6733689bd09a262bcb6ba4744e690dd4b37ebeac4ed427cf45242c4b4ce9a4"}, - {file = "rknn_toolkit_lite2-2.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3e4fefe355dc34a155680e4bcb9e4abb37ebc271f045ec9e0a4a3a018bc5beb"}, - {file = "rknn_toolkit_lite2-2.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37394371d1561f470c553f39869d7c35ff93405dffe3d0d72babf297a2b0aee9"}, - {file = "rknn_toolkit_lite2-2.3.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1983c3bcb82402f64997a5b937427f75c9f4532387d780824c1b9d4bf2bd0c72"}, - {file = "rknn_toolkit_lite2-2.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89466e1e8aa6f887de0f9ae94fabbd6db461b8134b5020d12b4228fcf239132e"}, - {file = "rknn_toolkit_lite2-2.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:728f574be91df6c78019fd9241cf2a8331adf74f6bb807e33a0b45e20a1f31a0"}, -] - -[package.dependencies] -numpy = "*" -psutil = "*" -"ruamel.yaml" = "*" - -[[package]] -name = "ruamel-yaml" -version = "0.18.6" -description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" -optional = false -python-versions = ">=3.7" -files = [ - {file = "ruamel.yaml-0.18.6-py3-none-any.whl", hash = "sha256:57b53ba33def16c4f3d807c0ccbc00f8a6081827e81ba2491691b76882d0c636"}, - {file = "ruamel.yaml-0.18.6.tar.gz", hash = "sha256:8b27e6a217e786c6fbe5634d8f3f11bc63e0f80f6a5890f28863d9c45aac311b"}, -] - -[package.dependencies] -"ruamel.yaml.clib" = {version = ">=0.2.7", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.13\""} - -[package.extras] -docs = ["mercurial (>5.7)", "ryd"] -jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] - -[[package]] -name = "ruamel-yaml-clib" -version = "0.2.12" -description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" -optional = false -python-versions = ">=3.9" -files = [ - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:11f891336688faf5156a36293a9c362bdc7c88f03a8a027c2c1d8e0bcde998e5"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:a606ef75a60ecf3d924613892cc603b154178ee25abb3055db5062da811fd969"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd5415dded15c3822597455bc02bcd66e81ef8b7a48cb71a33628fc9fdde39df"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:d84318609196d6bd6da0edfa25cedfbabd8dbde5140a0a23af29ad4b8f91fb1e"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb43a269eb827806502c7c8efb7ae7e9e9d0573257a46e8e952f4d4caba4f31e"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:943f32bc9dedb3abff9879edc134901df92cfce2c3d5c9348f172f62eb2d771d"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c3829bb364fdb8e0332c9931ecf57d9be3519241323c5274bd82f709cebc0c"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:e7e3736715fbf53e9be2a79eb4db68e4ed857017344d697e8b9749444ae57475"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7e75b4965e1d4690e93021adfcecccbca7d61c7bddd8e22406ef2ff20d74ef"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fc4b630cd3fa2cf7fce38afa91d7cfe844a9f75d7f0f36393fa98815e911d987"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:bc5f1e1c28e966d61d2519f2a3d451ba989f9ea0f2307de7bc45baa526de9e45"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a0e060aace4c24dcaf71023bbd7d42674e3b230f7e7b97317baf1e953e5b519"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2f1c3765db32be59d18ab3953f43ab62a761327aafc1594a2a1fbe038b8b8a7"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d85252669dc32f98ebcd5d36768f5d4faeaeaa2d655ac0473be490ecdae3c285"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e143ada795c341b56de9418c58d028989093ee611aa27ffb9b7f609c00d813ed"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win32.whl", hash = "sha256:beffaed67936fbbeffd10966a4eb53c402fafd3d6833770516bf7314bc6ffa12"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win_amd64.whl", hash = "sha256:040ae85536960525ea62868b642bdb0c2cc6021c9f9d507810c0c604e66f5a7b"}, - {file = "ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f"}, -] - -[[package]] -name = "ruff" -version = "0.9.2" -description = "An extremely fast Python linter and code formatter, written in Rust." -optional = false -python-versions = ">=3.7" -files = [ - {file = "ruff-0.9.2-py3-none-linux_armv6l.whl", hash = "sha256:80605a039ba1454d002b32139e4970becf84b5fee3a3c3bf1c2af6f61a784347"}, - {file = "ruff-0.9.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b9aab82bb20afd5f596527045c01e6ae25a718ff1784cb92947bff1f83068b00"}, - {file = "ruff-0.9.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fbd337bac1cfa96be615f6efcd4bc4d077edbc127ef30e2b8ba2a27e18c054d4"}, - {file = "ruff-0.9.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b35259b0cbf8daa22a498018e300b9bb0174c2bbb7bcba593935158a78054d"}, - {file = "ruff-0.9.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b6a9701d1e371bf41dca22015c3f89769da7576884d2add7317ec1ec8cb9c3c"}, - {file = "ruff-0.9.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9cc53e68b3c5ae41e8faf83a3b89f4a5d7b2cb666dff4b366bb86ed2a85b481f"}, - {file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8efd9da7a1ee314b910da155ca7e8953094a7c10d0c0a39bfde3fcfd2a015684"}, - {file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3292c5a22ea9a5f9a185e2d131dc7f98f8534a32fb6d2ee7b9944569239c648d"}, - {file = "ruff-0.9.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a605fdcf6e8b2d39f9436d343d1f0ff70c365a1e681546de0104bef81ce88df"}, - {file = "ruff-0.9.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c547f7f256aa366834829a08375c297fa63386cbe5f1459efaf174086b564247"}, - {file = "ruff-0.9.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d18bba3d3353ed916e882521bc3e0af403949dbada344c20c16ea78f47af965e"}, - {file = "ruff-0.9.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b338edc4610142355ccf6b87bd356729b62bf1bc152a2fad5b0c7dc04af77bfe"}, - {file = "ruff-0.9.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:492a5e44ad9b22a0ea98cf72e40305cbdaf27fac0d927f8bc9e1df316dcc96eb"}, - {file = "ruff-0.9.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:af1e9e9fe7b1f767264d26b1075ac4ad831c7db976911fa362d09b2d0356426a"}, - {file = "ruff-0.9.2-py3-none-win32.whl", hash = "sha256:71cbe22e178c5da20e1514e1e01029c73dc09288a8028a5d3446e6bba87a5145"}, - {file = "ruff-0.9.2-py3-none-win_amd64.whl", hash = "sha256:c5e1d6abc798419cf46eed03f54f2e0c3adb1ad4b801119dedf23fcaf69b55b5"}, - {file = "ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6"}, - {file = "ruff-0.9.2.tar.gz", hash = "sha256:b5eceb334d55fae5f316f783437392642ae18e16dcf4f1858d55d3c2a0f8f5d0"}, -] - -[[package]] -name = "scikit-image" -version = "0.22.0" -description = "Image processing in Python" -optional = false -python-versions = ">=3.9" -files = [ - {file = "scikit_image-0.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:74ec5c1d4693506842cc7c9487c89d8fc32aed064e9363def7af08b8f8cbb31d"}, - {file = "scikit_image-0.22.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:a05ae4fe03d802587ed8974e900b943275548cde6a6807b785039d63e9a7a5ff"}, - {file = "scikit_image-0.22.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a92dca3d95b1301442af055e196a54b5a5128c6768b79fc0a4098f1d662dee6"}, - {file = "scikit_image-0.22.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3663d063d8bf2fb9bdfb0ca967b9ee3b6593139c860c7abc2d2351a8a8863938"}, - {file = "scikit_image-0.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:ebdbdc901bae14dab637f8d5c99f6d5cc7aaf4a3b6f4003194e003e9f688a6fc"}, - {file = "scikit_image-0.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:95d6da2d8a44a36ae04437c76d32deb4e3c993ffc846b394b9949fd8ded73cb2"}, - {file = "scikit_image-0.22.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:2c6ef454a85f569659b813ac2a93948022b0298516b757c9c6c904132be327e2"}, - {file = "scikit_image-0.22.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e87872f067444ee90a00dd49ca897208308645382e8a24bd3e76f301af2352cd"}, - {file = "scikit_image-0.22.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5c378db54e61b491b9edeefff87e49fcf7fdf729bb93c777d7a5f15d36f743e"}, - {file = "scikit_image-0.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:2bcb74adb0634258a67f66c2bb29978c9a3e222463e003b67ba12056c003971b"}, - {file = "scikit_image-0.22.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:003ca2274ac0fac252280e7179ff986ff783407001459ddea443fe7916e38cff"}, - {file = "scikit_image-0.22.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:cf3c0c15b60ae3e557a0c7575fbd352f0c3ce0afca562febfe3ab80efbeec0e9"}, - {file = "scikit_image-0.22.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5b23908dd4d120e6aecb1ed0277563e8cbc8d6c0565bdc4c4c6475d53608452"}, - {file = "scikit_image-0.22.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be79d7493f320a964f8fcf603121595ba82f84720de999db0fcca002266a549a"}, - {file = "scikit_image-0.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:722b970aa5da725dca55252c373b18bbea7858c1cdb406e19f9b01a4a73b30b2"}, - {file = "scikit_image-0.22.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:22318b35044cfeeb63ee60c56fc62450e5fe516228138f1d06c7a26378248a86"}, - {file = "scikit_image-0.22.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:9e801c44a814afdadeabf4dffdffc23733e393767958b82319706f5fa3e1eaa9"}, - {file = "scikit_image-0.22.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c472a1fb3665ec5c00423684590631d95f9afcbc97f01407d348b821880b2cb3"}, - {file = "scikit_image-0.22.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b7a6c89e8d6252332121b58f50e1625c35f7d6a85489c0b6b7ee4f5155d547a"}, - {file = "scikit_image-0.22.0-cp39-cp39-win_amd64.whl", hash = "sha256:5071b8f6341bfb0737ab05c8ab4ac0261f9e25dbcc7b5d31e5ed230fd24a7929"}, - {file = "scikit_image-0.22.0.tar.gz", hash = "sha256:018d734df1d2da2719087d15f679d19285fce97cd37695103deadfaef2873236"}, -] - -[package.dependencies] -imageio = ">=2.27" -lazy_loader = ">=0.3" -networkx = ">=2.8" -numpy = ">=1.22" -packaging = ">=21" -pillow = ">=9.0.1" -scipy = ">=1.8" -tifffile = ">=2022.8.12" - -[package.extras] -build = ["Cython (>=0.29.32)", "build", "meson-python (>=0.14)", "ninja", "numpy (>=1.22)", "packaging (>=21)", "pythran", "setuptools (>=67)", "spin (==0.6)", "wheel"] -data = ["pooch (>=1.6.0)"] -developer = ["pre-commit", "tomli"] -docs = ["PyWavelets (>=1.1.1)", "dask[array] (>=2022.9.2)", "ipykernel", "ipywidgets", "kaleido", "matplotlib (>=3.5)", "myst-parser", "numpydoc (>=1.6)", "pandas (>=1.5)", "plotly (>=5.10)", "pooch (>=1.6)", "pydata-sphinx-theme (>=0.14.1)", "pytest-runner", "scikit-learn (>=1.1)", "seaborn (>=0.11)", "sphinx (>=7.2)", "sphinx-copybutton", "sphinx-gallery (>=0.14)", "sphinx_design (>=0.5)", "tifffile (>=2022.8.12)"] -optional = ["PyWavelets (>=1.1.1)", "SimpleITK", "astropy (>=5.0)", "cloudpickle (>=0.2.1)", "dask[array] (>=2021.1.0)", "matplotlib (>=3.5)", "pooch (>=1.6.0)", "pyamg", "scikit-learn (>=1.1)"] -test = ["asv", "matplotlib (>=3.5)", "numpydoc (>=1.5)", "pooch (>=1.6.0)", "pytest (>=7.0)", "pytest-cov (>=2.11.0)", "pytest-faulthandler", "pytest-localserver"] - -[[package]] -name = "scikit-learn" -version = "1.3.2" -description = "A set of python modules for machine learning and data mining" -optional = false -python-versions = ">=3.8" -files = [ - {file = "scikit-learn-1.3.2.tar.gz", hash = "sha256:a2f54c76accc15a34bfb9066e6c7a56c1e7235dda5762b990792330b52ccfb05"}, - {file = "scikit_learn-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e326c0eb5cf4d6ba40f93776a20e9a7a69524c4db0757e7ce24ba222471ee8a1"}, - {file = "scikit_learn-1.3.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:535805c2a01ccb40ca4ab7d081d771aea67e535153e35a1fd99418fcedd1648a"}, - {file = "scikit_learn-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1215e5e58e9880b554b01187b8c9390bf4dc4692eedeaf542d3273f4785e342c"}, - {file = "scikit_learn-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ee107923a623b9f517754ea2f69ea3b62fc898a3641766cb7deb2f2ce450161"}, - {file = "scikit_learn-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:35a22e8015048c628ad099da9df5ab3004cdbf81edc75b396fd0cff8699ac58c"}, - {file = "scikit_learn-1.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6fb6bc98f234fda43163ddbe36df8bcde1d13ee176c6dc9b92bb7d3fc842eb66"}, - {file = "scikit_learn-1.3.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:18424efee518a1cde7b0b53a422cde2f6625197de6af36da0b57ec502f126157"}, - {file = "scikit_learn-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3271552a5eb16f208a6f7f617b8cc6d1f137b52c8a1ef8edf547db0259b2c9fb"}, - {file = "scikit_learn-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4144a5004a676d5022b798d9e573b05139e77f271253a4703eed295bde0433"}, - {file = "scikit_learn-1.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:67f37d708f042a9b8d59551cf94d30431e01374e00dc2645fa186059c6c5d78b"}, - {file = "scikit_learn-1.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8db94cd8a2e038b37a80a04df8783e09caac77cbe052146432e67800e430c028"}, - {file = "scikit_learn-1.3.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:61a6efd384258789aa89415a410dcdb39a50e19d3d8410bd29be365bcdd512d5"}, - {file = "scikit_learn-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb06f8dce3f5ddc5dee1715a9b9f19f20d295bed8e3cd4fa51e1d050347de525"}, - {file = "scikit_learn-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5b2de18d86f630d68fe1f87af690d451388bb186480afc719e5f770590c2ef6c"}, - {file = "scikit_learn-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:0402638c9a7c219ee52c94cbebc8fcb5eb9fe9c773717965c1f4185588ad3107"}, - {file = "scikit_learn-1.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a19f90f95ba93c1a7f7924906d0576a84da7f3b2282ac3bfb7a08a32801add93"}, - {file = "scikit_learn-1.3.2-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:b8692e395a03a60cd927125eef3a8e3424d86dde9b2370d544f0ea35f78a8073"}, - {file = "scikit_learn-1.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15e1e94cc23d04d39da797ee34236ce2375ddea158b10bee3c343647d615581d"}, - {file = "scikit_learn-1.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:785a2213086b7b1abf037aeadbbd6d67159feb3e30263434139c98425e3dcfcf"}, - {file = "scikit_learn-1.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:64381066f8aa63c2710e6b56edc9f0894cc7bf59bd71b8ce5613a4559b6145e0"}, - {file = "scikit_learn-1.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6c43290337f7a4b969d207e620658372ba3c1ffb611f8bc2b6f031dc5c6d1d03"}, - {file = "scikit_learn-1.3.2-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:dc9002fc200bed597d5d34e90c752b74df516d592db162f756cc52836b38fe0e"}, - {file = "scikit_learn-1.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d08ada33e955c54355d909b9c06a4789a729977f165b8bae6f225ff0a60ec4a"}, - {file = "scikit_learn-1.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:763f0ae4b79b0ff9cca0bf3716bcc9915bdacff3cebea15ec79652d1cc4fa5c9"}, - {file = "scikit_learn-1.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:ed932ea780517b00dae7431e031faae6b49b20eb6950918eb83bd043237950e0"}, -] - -[package.dependencies] -joblib = ">=1.1.1" -numpy = ">=1.17.3,<2.0" -scipy = ">=1.5.0" -threadpoolctl = ">=2.0.0" - -[package.extras] -benchmark = ["matplotlib (>=3.1.3)", "memory-profiler (>=0.57.0)", "pandas (>=1.0.5)"] -docs = ["Pillow (>=7.1.2)", "matplotlib (>=3.1.3)", "memory-profiler (>=0.57.0)", "numpydoc (>=1.2.0)", "pandas (>=1.0.5)", "plotly (>=5.14.0)", "pooch (>=1.6.0)", "scikit-image (>=0.16.2)", "seaborn (>=0.9.0)", "sphinx (>=6.0.0)", "sphinx-copybutton (>=0.5.2)", "sphinx-gallery (>=0.10.1)", "sphinx-prompt (>=1.3.0)", "sphinxext-opengraph (>=0.4.2)"] -examples = ["matplotlib (>=3.1.3)", "pandas (>=1.0.5)", "plotly (>=5.14.0)", "pooch (>=1.6.0)", "scikit-image (>=0.16.2)", "seaborn (>=0.9.0)"] -tests = ["black (>=23.3.0)", "matplotlib (>=3.1.3)", "mypy (>=1.3)", "numpydoc (>=1.2.0)", "pandas (>=1.0.5)", "pooch (>=1.6.0)", "pyamg (>=4.0.0)", "pytest (>=7.1.2)", "pytest-cov (>=2.9.0)", "ruff (>=0.0.272)", "scikit-image (>=0.16.2)"] - -[[package]] -name = "scipy" -version = "1.11.4" -description = "Fundamental algorithms for scientific computing in Python" -optional = false -python-versions = ">=3.9" -files = [ - {file = "scipy-1.11.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc9a714581f561af0848e6b69947fda0614915f072dfd14142ed1bfe1b806710"}, - {file = "scipy-1.11.4-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:cf00bd2b1b0211888d4dc75656c0412213a8b25e80d73898083f402b50f47e41"}, - {file = "scipy-1.11.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9999c008ccf00e8fbcce1236f85ade5c569d13144f77a1946bef8863e8f6eb4"}, - {file = "scipy-1.11.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:933baf588daa8dc9a92c20a0be32f56d43faf3d1a60ab11b3f08c356430f6e56"}, - {file = "scipy-1.11.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8fce70f39076a5aa62e92e69a7f62349f9574d8405c0a5de6ed3ef72de07f446"}, - {file = "scipy-1.11.4-cp310-cp310-win_amd64.whl", hash = "sha256:6550466fbeec7453d7465e74d4f4b19f905642c89a7525571ee91dd7adabb5a3"}, - {file = "scipy-1.11.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f313b39a7e94f296025e3cffc2c567618174c0b1dde173960cf23808f9fae4be"}, - {file = "scipy-1.11.4-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:1b7c3dca977f30a739e0409fb001056484661cb2541a01aba0bb0029f7b68db8"}, - {file = "scipy-1.11.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00150c5eae7b610c32589dda259eacc7c4f1665aedf25d921907f4d08a951b1c"}, - {file = "scipy-1.11.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:530f9ad26440e85766509dbf78edcfe13ffd0ab7fec2560ee5c36ff74d6269ff"}, - {file = "scipy-1.11.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5e347b14fe01003d3b78e196e84bd3f48ffe4c8a7b8a1afbcb8f5505cb710993"}, - {file = "scipy-1.11.4-cp311-cp311-win_amd64.whl", hash = "sha256:acf8ed278cc03f5aff035e69cb511741e0418681d25fbbb86ca65429c4f4d9cd"}, - {file = "scipy-1.11.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:028eccd22e654b3ea01ee63705681ee79933652b2d8f873e7949898dda6d11b6"}, - {file = "scipy-1.11.4-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:2c6ff6ef9cc27f9b3db93a6f8b38f97387e6e0591600369a297a50a8e96e835d"}, - {file = "scipy-1.11.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b030c6674b9230d37c5c60ab456e2cf12f6784596d15ce8da9365e70896effc4"}, - {file = "scipy-1.11.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad669df80528aeca5f557712102538f4f37e503f0c5b9541655016dd0932ca79"}, - {file = "scipy-1.11.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ce7fff2e23ab2cc81ff452a9444c215c28e6305f396b2ba88343a567feec9660"}, - {file = "scipy-1.11.4-cp312-cp312-win_amd64.whl", hash = "sha256:36750b7733d960d7994888f0d148d31ea3017ac15eef664194b4ef68d36a4a97"}, - {file = "scipy-1.11.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6e619aba2df228a9b34718efb023966da781e89dd3d21637b27f2e54db0410d7"}, - {file = "scipy-1.11.4-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:f3cd9e7b3c2c1ec26364856f9fbe78695fe631150f94cd1c22228456404cf1ec"}, - {file = "scipy-1.11.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d10e45a6c50211fe256da61a11c34927c68f277e03138777bdebedd933712fea"}, - {file = "scipy-1.11.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91af76a68eeae0064887a48e25c4e616fa519fa0d38602eda7e0f97d65d57937"}, - {file = "scipy-1.11.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6df1468153a31cf55ed5ed39647279beb9cfb5d3f84369453b49e4b8502394fd"}, - {file = "scipy-1.11.4-cp39-cp39-win_amd64.whl", hash = "sha256:ee410e6de8f88fd5cf6eadd73c135020bfbbbdfcd0f6162c36a7638a1ea8cc65"}, - {file = "scipy-1.11.4.tar.gz", hash = "sha256:90a2b78e7f5733b9de748f589f09225013685f9b218275257f8a8168ededaeaa"}, -] - -[package.dependencies] -numpy = ">=1.21.6,<1.28.0" - -[package.extras] -dev = ["click", "cython-lint (>=0.12.2)", "doit (>=0.36.0)", "mypy", "pycodestyle", "pydevtool", "rich-click", "ruff", "types-psutil", "typing_extensions"] -doc = ["jupytext", "matplotlib (>2)", "myst-nb", "numpydoc", "pooch", "pydata-sphinx-theme (==0.9.0)", "sphinx (!=4.1.0)", "sphinx-design (>=0.2.0)"] -test = ["asv", "gmpy2", "mpmath", "pooch", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"] - -[[package]] -name = "setuptools" -version = "70.3.0" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "setuptools-70.3.0-py3-none-any.whl", hash = "sha256:fe384da74336c398e0d956d1cae0669bc02eed936cdb1d49b57de1990dc11ffc"}, - {file = "setuptools-70.3.0.tar.gz", hash = "sha256:f171bab1dfbc86b132997f26a119f6056a57950d058587841a0082e8830f9dc5"}, -] - -[package.extras] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.10.0)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, -] - -[[package]] -name = "sniffio" -version = "1.3.0" -description = "Sniff out which async library your code is running under" -optional = false -python-versions = ">=3.7" -files = [ - {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, - {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, -] - -[[package]] -name = "starlette" -version = "0.41.2" -description = "The little ASGI library that shines." -optional = false -python-versions = ">=3.8" -files = [ - {file = "starlette-0.41.2-py3-none-any.whl", hash = "sha256:fbc189474b4731cf30fcef52f18a8d070e3f3b46c6a04c97579e85e6ffca942d"}, - {file = "starlette-0.41.2.tar.gz", hash = "sha256:9834fd799d1a87fd346deb76158668cfa0b0d56f85caefe8268e2d97c3468b62"}, -] - -[package.dependencies] -anyio = ">=3.4.0,<5" - -[package.extras] -full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] - -[[package]] -name = "sympy" -version = "1.12" -description = "Computer algebra system (CAS) in Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "sympy-1.12-py3-none-any.whl", hash = "sha256:c3588cd4295d0c0f603d0f2ae780587e64e2efeedb3521e46b9bb1d08d184fa5"}, - {file = "sympy-1.12.tar.gz", hash = "sha256:ebf595c8dac3e0fdc4152c51878b498396ec7f30e7a914d6071e674d49420fb8"}, -] - -[package.dependencies] -mpmath = ">=0.19" - -[[package]] -name = "threadpoolctl" -version = "3.2.0" -description = "threadpoolctl" -optional = false -python-versions = ">=3.8" -files = [ - {file = "threadpoolctl-3.2.0-py3-none-any.whl", hash = "sha256:2b7818516e423bdaebb97c723f86a7c6b0a83d3f3b0970328d66f4d9104dc032"}, - {file = "threadpoolctl-3.2.0.tar.gz", hash = "sha256:c96a0ba3bdddeaca37dc4cc7344aafad41cdb8c313f74fdfe387a867bba93355"}, -] - -[[package]] -name = "tifffile" -version = "2023.12.9" -description = "Read and write TIFF files" -optional = false -python-versions = ">=3.9" -files = [ - {file = "tifffile-2023.12.9-py3-none-any.whl", hash = "sha256:9b066e4b1a900891ea42ffd33dab8ba34c537935618b9893ddef42d7d422692f"}, - {file = "tifffile-2023.12.9.tar.gz", hash = "sha256:9dd1da91180a6453018a241ff219e1905f169384355cd89c9ef4034c1b46cdb8"}, -] - -[package.dependencies] -numpy = "*" - -[package.extras] -all = ["defusedxml", "fsspec", "imagecodecs (>=2023.8.12)", "lxml", "matplotlib", "zarr"] - -[[package]] -name = "tokenizers" -version = "0.21.0" -description = "" -optional = false -python-versions = ">=3.7" -files = [ - {file = "tokenizers-0.21.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:3c4c93eae637e7d2aaae3d376f06085164e1660f89304c0ab2b1d08a406636b2"}, - {file = "tokenizers-0.21.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:f53ea537c925422a2e0e92a24cce96f6bc5046bbef24a1652a5edc8ba975f62e"}, - {file = "tokenizers-0.21.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b177fb54c4702ef611de0c069d9169f0004233890e0c4c5bd5508ae05abf193"}, - {file = "tokenizers-0.21.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6b43779a269f4629bebb114e19c3fca0223296ae9fea8bb9a7a6c6fb0657ff8e"}, - {file = "tokenizers-0.21.0-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9aeb255802be90acfd363626753fda0064a8df06031012fe7d52fd9a905eb00e"}, - {file = "tokenizers-0.21.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8b09dbeb7a8d73ee204a70f94fc06ea0f17dcf0844f16102b9f414f0b7463ba"}, - {file = "tokenizers-0.21.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:400832c0904f77ce87c40f1a8a27493071282f785724ae62144324f171377273"}, - {file = "tokenizers-0.21.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84ca973b3a96894d1707e189c14a774b701596d579ffc7e69debfc036a61a04"}, - {file = "tokenizers-0.21.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:eb7202d231b273c34ec67767378cd04c767e967fda12d4a9e36208a34e2f137e"}, - {file = "tokenizers-0.21.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:089d56db6782a73a27fd8abf3ba21779f5b85d4a9f35e3b493c7bbcbbf0d539b"}, - {file = "tokenizers-0.21.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:c87ca3dc48b9b1222d984b6b7490355a6fdb411a2d810f6f05977258400ddb74"}, - {file = "tokenizers-0.21.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4145505a973116f91bc3ac45988a92e618a6f83eb458f49ea0790df94ee243ff"}, - {file = "tokenizers-0.21.0-cp39-abi3-win32.whl", hash = "sha256:eb1702c2f27d25d9dd5b389cc1f2f51813e99f8ca30d9e25348db6585a97e24a"}, - {file = "tokenizers-0.21.0-cp39-abi3-win_amd64.whl", hash = "sha256:87841da5a25a3a5f70c102de371db120f41873b854ba65e52bccd57df5a3780c"}, - {file = "tokenizers-0.21.0.tar.gz", hash = "sha256:ee0894bf311b75b0c03079f33859ae4b2334d675d4e93f5a4132e1eae2834fe4"}, -] - -[package.dependencies] -huggingface-hub = ">=0.16.4,<1.0" - -[package.extras] -dev = ["tokenizers[testing]"] -docs = ["setuptools-rust", "sphinx", "sphinx-rtd-theme"] -testing = ["black (==22.3)", "datasets", "numpy", "pytest", "requests", "ruff"] - -[[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.7" -files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] - -[[package]] -name = "tqdm" -version = "4.66.3" -description = "Fast, Extensible Progress Meter" -optional = false -python-versions = ">=3.7" -files = [ - {file = "tqdm-4.66.3-py3-none-any.whl", hash = "sha256:4f41d54107ff9a223dca80b53efe4fb654c67efaba7f47bada3ee9d50e05bd53"}, - {file = "tqdm-4.66.3.tar.gz", hash = "sha256:23097a41eba115ba99ecae40d06444c15d1c0c698d527a01c6c8bd1c5d0647e5"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[package.extras] -dev = ["pytest (>=6)", "pytest-cov", "pytest-timeout", "pytest-xdist"] -notebook = ["ipywidgets (>=6)"] -slack = ["slack-sdk"] -telegram = ["requests"] - -[[package]] -name = "typing-extensions" -version = "4.12.2" -description = "Backported and Experimental Type Hints for Python 3.8+" -optional = false -python-versions = ">=3.8" -files = [ - {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, - {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, -] - -[[package]] -name = "urllib3" -version = "2.1.0" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.8" -files = [ - {file = "urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3"}, - {file = "urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54"}, -] - -[package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "uvicorn" -version = "0.34.0" -description = "The lightning-fast ASGI server." -optional = false -python-versions = ">=3.9" -files = [ - {file = "uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4"}, - {file = "uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9"}, -] - -[package.dependencies] -click = ">=7.0" -colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""} -h11 = ">=0.8" -httptools = {version = ">=0.6.3", optional = true, markers = "extra == \"standard\""} -python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} -pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} -typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} -uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""} -watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} -websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} - -[package.extras] -standard = ["colorama (>=0.4)", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] - -[[package]] -name = "uvloop" -version = "0.19.0" -description = "Fast implementation of asyncio event loop on top of libuv" -optional = false -python-versions = ">=3.8.0" -files = [ - {file = "uvloop-0.19.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:de4313d7f575474c8f5a12e163f6d89c0a878bc49219641d49e6f1444369a90e"}, - {file = "uvloop-0.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5588bd21cf1fcf06bded085f37e43ce0e00424197e7c10e77afd4bbefffef428"}, - {file = "uvloop-0.19.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b1fd71c3843327f3bbc3237bedcdb6504fd50368ab3e04d0410e52ec293f5b8"}, - {file = "uvloop-0.19.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a05128d315e2912791de6088c34136bfcdd0c7cbc1cf85fd6fd1bb321b7c849"}, - {file = "uvloop-0.19.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:cd81bdc2b8219cb4b2556eea39d2e36bfa375a2dd021404f90a62e44efaaf957"}, - {file = "uvloop-0.19.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5f17766fb6da94135526273080f3455a112f82570b2ee5daa64d682387fe0dcd"}, - {file = "uvloop-0.19.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4ce6b0af8f2729a02a5d1575feacb2a94fc7b2e983868b009d51c9a9d2149bef"}, - {file = "uvloop-0.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:31e672bb38b45abc4f26e273be83b72a0d28d074d5b370fc4dcf4c4eb15417d2"}, - {file = "uvloop-0.19.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:570fc0ed613883d8d30ee40397b79207eedd2624891692471808a95069a007c1"}, - {file = "uvloop-0.19.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5138821e40b0c3e6c9478643b4660bd44372ae1e16a322b8fc07478f92684e24"}, - {file = "uvloop-0.19.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:91ab01c6cd00e39cde50173ba4ec68a1e578fee9279ba64f5221810a9e786533"}, - {file = "uvloop-0.19.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:47bf3e9312f63684efe283f7342afb414eea4d3011542155c7e625cd799c3b12"}, - {file = "uvloop-0.19.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:da8435a3bd498419ee8c13c34b89b5005130a476bda1d6ca8cfdde3de35cd650"}, - {file = "uvloop-0.19.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:02506dc23a5d90e04d4f65c7791e65cf44bd91b37f24cfc3ef6cf2aff05dc7ec"}, - {file = "uvloop-0.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2693049be9d36fef81741fddb3f441673ba12a34a704e7b4361efb75cf30befc"}, - {file = "uvloop-0.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7010271303961c6f0fe37731004335401eb9075a12680738731e9c92ddd96ad6"}, - {file = "uvloop-0.19.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5daa304d2161d2918fa9a17d5635099a2f78ae5b5960e742b2fcfbb7aefaa593"}, - {file = "uvloop-0.19.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7207272c9520203fea9b93843bb775d03e1cf88a80a936ce760f60bb5add92f3"}, - {file = "uvloop-0.19.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:78ab247f0b5671cc887c31d33f9b3abfb88d2614b84e4303f1a63b46c046c8bd"}, - {file = "uvloop-0.19.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:472d61143059c84947aa8bb74eabbace30d577a03a1805b77933d6bd13ddebbd"}, - {file = "uvloop-0.19.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45bf4c24c19fb8a50902ae37c5de50da81de4922af65baf760f7c0c42e1088be"}, - {file = "uvloop-0.19.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271718e26b3e17906b28b67314c45d19106112067205119dddbd834c2b7ce797"}, - {file = "uvloop-0.19.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:34175c9fd2a4bc3adc1380e1261f60306344e3407c20a4d684fd5f3be010fa3d"}, - {file = "uvloop-0.19.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e27f100e1ff17f6feeb1f33968bc185bf8ce41ca557deee9d9bbbffeb72030b7"}, - {file = "uvloop-0.19.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:13dfdf492af0aa0a0edf66807d2b465607d11c4fa48f4a1fd41cbea5b18e8e8b"}, - {file = "uvloop-0.19.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6e3d4e85ac060e2342ff85e90d0c04157acb210b9ce508e784a944f852a40e67"}, - {file = "uvloop-0.19.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca4956c9ab567d87d59d49fa3704cf29e37109ad348f2d5223c9bf761a332e7"}, - {file = "uvloop-0.19.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f467a5fd23b4fc43ed86342641f3936a68ded707f4627622fa3f82a120e18256"}, - {file = "uvloop-0.19.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:492e2c32c2af3f971473bc22f086513cedfc66a130756145a931a90c3958cb17"}, - {file = "uvloop-0.19.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2df95fca285a9f5bfe730e51945ffe2fa71ccbfdde3b0da5772b4ee4f2e770d5"}, - {file = "uvloop-0.19.0.tar.gz", hash = "sha256:0246f4fd1bf2bf702e06b0d45ee91677ee5c31242f39aab4ea6fe0c51aedd0fd"}, -] - -[package.extras] -docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] -test = ["Cython (>=0.29.36,<0.30.0)", "aiohttp (==3.9.0b0)", "aiohttp (>=3.8.1)", "flake8 (>=5.0,<6.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=23.0.0,<23.1.0)", "pycodestyle (>=2.9.0,<2.10.0)"] - -[[package]] -name = "watchfiles" -version = "0.21.0" -description = "Simple, modern and high performance file watching and code reload in python." -optional = false -python-versions = ">=3.8" -files = [ - {file = "watchfiles-0.21.0-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:27b4035013f1ea49c6c0b42d983133b136637a527e48c132d368eb19bf1ac6aa"}, - {file = "watchfiles-0.21.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c81818595eff6e92535ff32825f31c116f867f64ff8cdf6562cd1d6b2e1e8f3e"}, - {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6c107ea3cf2bd07199d66f156e3ea756d1b84dfd43b542b2d870b77868c98c03"}, - {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d9ac347653ebd95839a7c607608703b20bc07e577e870d824fa4801bc1cb124"}, - {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5eb86c6acb498208e7663ca22dbe68ca2cf42ab5bf1c776670a50919a56e64ab"}, - {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f564bf68404144ea6b87a78a3f910cc8de216c6b12a4cf0b27718bf4ec38d303"}, - {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d0f32ebfaa9c6011f8454994f86108c2eb9c79b8b7de00b36d558cadcedaa3d"}, - {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6d45d9b699ecbac6c7bd8e0a2609767491540403610962968d258fd6405c17c"}, - {file = "watchfiles-0.21.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:aff06b2cac3ef4616e26ba17a9c250c1fe9dd8a5d907d0193f84c499b1b6e6a9"}, - {file = "watchfiles-0.21.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d9792dff410f266051025ecfaa927078b94cc7478954b06796a9756ccc7e14a9"}, - {file = "watchfiles-0.21.0-cp310-none-win32.whl", hash = "sha256:214cee7f9e09150d4fb42e24919a1e74d8c9b8a9306ed1474ecaddcd5479c293"}, - {file = "watchfiles-0.21.0-cp310-none-win_amd64.whl", hash = "sha256:1ad7247d79f9f55bb25ab1778fd47f32d70cf36053941f07de0b7c4e96b5d235"}, - {file = "watchfiles-0.21.0-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:668c265d90de8ae914f860d3eeb164534ba2e836811f91fecc7050416ee70aa7"}, - {file = "watchfiles-0.21.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a23092a992e61c3a6a70f350a56db7197242f3490da9c87b500f389b2d01eef"}, - {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e7941bbcfdded9c26b0bf720cb7e6fd803d95a55d2c14b4bd1f6a2772230c586"}, - {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11cd0c3100e2233e9c53106265da31d574355c288e15259c0d40a4405cbae317"}, - {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78f30cbe8b2ce770160d3c08cff01b2ae9306fe66ce899b73f0409dc1846c1b"}, - {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6674b00b9756b0af620aa2a3346b01f8e2a3dc729d25617e1b89cf6af4a54eb1"}, - {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fd7ac678b92b29ba630d8c842d8ad6c555abda1b9ef044d6cc092dacbfc9719d"}, - {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c873345680c1b87f1e09e0eaf8cf6c891b9851d8b4d3645e7efe2ec20a20cc7"}, - {file = "watchfiles-0.21.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:49f56e6ecc2503e7dbe233fa328b2be1a7797d31548e7a193237dcdf1ad0eee0"}, - {file = "watchfiles-0.21.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:02d91cbac553a3ad141db016e3350b03184deaafeba09b9d6439826ee594b365"}, - {file = "watchfiles-0.21.0-cp311-none-win32.whl", hash = "sha256:ebe684d7d26239e23d102a2bad2a358dedf18e462e8808778703427d1f584400"}, - {file = "watchfiles-0.21.0-cp311-none-win_amd64.whl", hash = "sha256:4566006aa44cb0d21b8ab53baf4b9c667a0ed23efe4aaad8c227bfba0bf15cbe"}, - {file = "watchfiles-0.21.0-cp311-none-win_arm64.whl", hash = "sha256:c550a56bf209a3d987d5a975cdf2063b3389a5d16caf29db4bdddeae49f22078"}, - {file = "watchfiles-0.21.0-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:51ddac60b96a42c15d24fbdc7a4bfcd02b5a29c047b7f8bf63d3f6f5a860949a"}, - {file = "watchfiles-0.21.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:511f0b034120cd1989932bf1e9081aa9fb00f1f949fbd2d9cab6264916ae89b1"}, - {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:cfb92d49dbb95ec7a07511bc9efb0faff8fe24ef3805662b8d6808ba8409a71a"}, - {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f92944efc564867bbf841c823c8b71bb0be75e06b8ce45c084b46411475a915"}, - {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:642d66b75eda909fd1112d35c53816d59789a4b38c141a96d62f50a3ef9b3360"}, - {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d23bcd6c8eaa6324fe109d8cac01b41fe9a54b8c498af9ce464c1aeeb99903d6"}, - {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18d5b4da8cf3e41895b34e8c37d13c9ed294954907929aacd95153508d5d89d7"}, - {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b8d1eae0f65441963d805f766c7e9cd092f91e0c600c820c764a4ff71a0764c"}, - {file = "watchfiles-0.21.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1fd9a5205139f3c6bb60d11f6072e0552f0a20b712c85f43d42342d162be1235"}, - {file = "watchfiles-0.21.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a1e3014a625bcf107fbf38eece0e47fa0190e52e45dc6eee5a8265ddc6dc5ea7"}, - {file = "watchfiles-0.21.0-cp312-none-win32.whl", hash = "sha256:9d09869f2c5a6f2d9df50ce3064b3391d3ecb6dced708ad64467b9e4f2c9bef3"}, - {file = "watchfiles-0.21.0-cp312-none-win_amd64.whl", hash = "sha256:18722b50783b5e30a18a8a5db3006bab146d2b705c92eb9a94f78c72beb94094"}, - {file = "watchfiles-0.21.0-cp312-none-win_arm64.whl", hash = "sha256:a3b9bec9579a15fb3ca2d9878deae789df72f2b0fdaf90ad49ee389cad5edab6"}, - {file = "watchfiles-0.21.0-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:4ea10a29aa5de67de02256a28d1bf53d21322295cb00bd2d57fcd19b850ebd99"}, - {file = "watchfiles-0.21.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:40bca549fdc929b470dd1dbfcb47b3295cb46a6d2c90e50588b0a1b3bd98f429"}, - {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9b37a7ba223b2f26122c148bb8d09a9ff312afca998c48c725ff5a0a632145f7"}, - {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec8c8900dc5c83650a63dd48c4d1d245343f904c4b64b48798c67a3767d7e165"}, - {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8ad3fe0a3567c2f0f629d800409cd528cb6251da12e81a1f765e5c5345fd0137"}, - {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d353c4cfda586db2a176ce42c88f2fc31ec25e50212650c89fdd0f560ee507b"}, - {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:83a696da8922314ff2aec02987eefb03784f473281d740bf9170181829133765"}, - {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a03651352fc20975ee2a707cd2d74a386cd303cc688f407296064ad1e6d1562"}, - {file = "watchfiles-0.21.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3ad692bc7792be8c32918c699638b660c0de078a6cbe464c46e1340dadb94c19"}, - {file = "watchfiles-0.21.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06247538e8253975bdb328e7683f8515ff5ff041f43be6c40bff62d989b7d0b0"}, - {file = "watchfiles-0.21.0-cp38-none-win32.whl", hash = "sha256:9a0aa47f94ea9a0b39dd30850b0adf2e1cd32a8b4f9c7aa443d852aacf9ca214"}, - {file = "watchfiles-0.21.0-cp38-none-win_amd64.whl", hash = "sha256:8d5f400326840934e3507701f9f7269247f7c026d1b6cfd49477d2be0933cfca"}, - {file = "watchfiles-0.21.0-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:7f762a1a85a12cc3484f77eee7be87b10f8c50b0b787bb02f4e357403cad0c0e"}, - {file = "watchfiles-0.21.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6e9be3ef84e2bb9710f3f777accce25556f4a71e15d2b73223788d528fcc2052"}, - {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4c48a10d17571d1275701e14a601e36959ffada3add8cdbc9e5061a6e3579a5d"}, - {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c889025f59884423428c261f212e04d438de865beda0b1e1babab85ef4c0f01"}, - {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:66fac0c238ab9a2e72d026b5fb91cb902c146202bbd29a9a1a44e8db7b710b6f"}, - {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b4a21f71885aa2744719459951819e7bf5a906a6448a6b2bbce8e9cc9f2c8128"}, - {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c9198c989f47898b2c22201756f73249de3748e0fc9de44adaf54a8b259cc0c"}, - {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8f57c4461cd24fda22493109c45b3980863c58a25b8bec885ca8bea6b8d4b28"}, - {file = "watchfiles-0.21.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:853853cbf7bf9408b404754b92512ebe3e3a83587503d766d23e6bf83d092ee6"}, - {file = "watchfiles-0.21.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d5b1dc0e708fad9f92c296ab2f948af403bf201db8fb2eb4c8179db143732e49"}, - {file = "watchfiles-0.21.0-cp39-none-win32.whl", hash = "sha256:59137c0c6826bd56c710d1d2bda81553b5e6b7c84d5a676747d80caf0409ad94"}, - {file = "watchfiles-0.21.0-cp39-none-win_amd64.whl", hash = "sha256:6cb8fdc044909e2078c248986f2fc76f911f72b51ea4a4fbbf472e01d14faa58"}, - {file = "watchfiles-0.21.0-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:ab03a90b305d2588e8352168e8c5a1520b721d2d367f31e9332c4235b30b8994"}, - {file = "watchfiles-0.21.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:927c589500f9f41e370b0125c12ac9e7d3a2fd166b89e9ee2828b3dda20bfe6f"}, - {file = "watchfiles-0.21.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bd467213195e76f838caf2c28cd65e58302d0254e636e7c0fca81efa4a2e62c"}, - {file = "watchfiles-0.21.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02b73130687bc3f6bb79d8a170959042eb56eb3a42df3671c79b428cd73f17cc"}, - {file = "watchfiles-0.21.0-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:08dca260e85ffae975448e344834d765983237ad6dc308231aa16e7933db763e"}, - {file = "watchfiles-0.21.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:3ccceb50c611c433145502735e0370877cced72a6c70fd2410238bcbc7fe51d8"}, - {file = "watchfiles-0.21.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57d430f5fb63fea141ab71ca9c064e80de3a20b427ca2febcbfcef70ff0ce895"}, - {file = "watchfiles-0.21.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dd5fad9b9c0dd89904bbdea978ce89a2b692a7ee8a0ce19b940e538c88a809c"}, - {file = "watchfiles-0.21.0-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:be6dd5d52b73018b21adc1c5d28ac0c68184a64769052dfeb0c5d9998e7f56a2"}, - {file = "watchfiles-0.21.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:b3cab0e06143768499384a8a5efb9c4dc53e19382952859e4802f294214f36ec"}, - {file = "watchfiles-0.21.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c6ed10c2497e5fedadf61e465b3ca12a19f96004c15dcffe4bd442ebadc2d85"}, - {file = "watchfiles-0.21.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43babacef21c519bc6631c5fce2a61eccdfc011b4bcb9047255e9620732c8097"}, - {file = "watchfiles-0.21.0.tar.gz", hash = "sha256:c76c635fabf542bb78524905718c39f736a98e5ab25b23ec6d4abede1a85a6a3"}, -] - -[package.dependencies] -anyio = ">=3.0.0" - -[[package]] -name = "wcwidth" -version = "0.2.13" -description = "Measures the displayed width of unicode strings in a terminal" -optional = false -python-versions = "*" -files = [ - {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, - {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, -] - -[[package]] -name = "websockets" -version = "12.0" -description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" -optional = false -python-versions = ">=3.8" -files = [ - {file = "websockets-12.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d554236b2a2006e0ce16315c16eaa0d628dab009c33b63ea03f41c6107958374"}, - {file = "websockets-12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2d225bb6886591b1746b17c0573e29804619c8f755b5598d875bb4235ea639be"}, - {file = "websockets-12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eb809e816916a3b210bed3c82fb88eaf16e8afcf9c115ebb2bacede1797d2547"}, - {file = "websockets-12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c588f6abc13f78a67044c6b1273a99e1cf31038ad51815b3b016ce699f0d75c2"}, - {file = "websockets-12.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5aa9348186d79a5f232115ed3fa9020eab66d6c3437d72f9d2c8ac0c6858c558"}, - {file = "websockets-12.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6350b14a40c95ddd53e775dbdbbbc59b124a5c8ecd6fbb09c2e52029f7a9f480"}, - {file = "websockets-12.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:70ec754cc2a769bcd218ed8d7209055667b30860ffecb8633a834dde27d6307c"}, - {file = "websockets-12.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6e96f5ed1b83a8ddb07909b45bd94833b0710f738115751cdaa9da1fb0cb66e8"}, - {file = "websockets-12.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4d87be612cbef86f994178d5186add3d94e9f31cc3cb499a0482b866ec477603"}, - {file = "websockets-12.0-cp310-cp310-win32.whl", hash = "sha256:befe90632d66caaf72e8b2ed4d7f02b348913813c8b0a32fae1cc5fe3730902f"}, - {file = "websockets-12.0-cp310-cp310-win_amd64.whl", hash = "sha256:363f57ca8bc8576195d0540c648aa58ac18cf85b76ad5202b9f976918f4219cf"}, - {file = "websockets-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5d873c7de42dea355d73f170be0f23788cf3fa9f7bed718fd2830eefedce01b4"}, - {file = "websockets-12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3f61726cae9f65b872502ff3c1496abc93ffbe31b278455c418492016e2afc8f"}, - {file = "websockets-12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed2fcf7a07334c77fc8a230755c2209223a7cc44fc27597729b8ef5425aa61a3"}, - {file = "websockets-12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e332c210b14b57904869ca9f9bf4ca32f5427a03eeb625da9b616c85a3a506c"}, - {file = "websockets-12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5693ef74233122f8ebab026817b1b37fe25c411ecfca084b29bc7d6efc548f45"}, - {file = "websockets-12.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e9e7db18b4539a29cc5ad8c8b252738a30e2b13f033c2d6e9d0549b45841c04"}, - {file = "websockets-12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6e2df67b8014767d0f785baa98393725739287684b9f8d8a1001eb2839031447"}, - {file = "websockets-12.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bea88d71630c5900690fcb03161ab18f8f244805c59e2e0dc4ffadae0a7ee0ca"}, - {file = "websockets-12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dff6cdf35e31d1315790149fee351f9e52978130cef6c87c4b6c9b3baf78bc53"}, - {file = "websockets-12.0-cp311-cp311-win32.whl", hash = "sha256:3e3aa8c468af01d70332a382350ee95f6986db479ce7af14d5e81ec52aa2b402"}, - {file = "websockets-12.0-cp311-cp311-win_amd64.whl", hash = "sha256:25eb766c8ad27da0f79420b2af4b85d29914ba0edf69f547cc4f06ca6f1d403b"}, - {file = "websockets-12.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0e6e2711d5a8e6e482cacb927a49a3d432345dfe7dea8ace7b5790df5932e4df"}, - {file = "websockets-12.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:dbcf72a37f0b3316e993e13ecf32f10c0e1259c28ffd0a85cee26e8549595fbc"}, - {file = "websockets-12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12743ab88ab2af1d17dd4acb4645677cb7063ef4db93abffbf164218a5d54c6b"}, - {file = "websockets-12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b645f491f3c48d3f8a00d1fce07445fab7347fec54a3e65f0725d730d5b99cb"}, - {file = "websockets-12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9893d1aa45a7f8b3bc4510f6ccf8db8c3b62120917af15e3de247f0780294b92"}, - {file = "websockets-12.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f38a7b376117ef7aff996e737583172bdf535932c9ca021746573bce40165ed"}, - {file = "websockets-12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f764ba54e33daf20e167915edc443b6f88956f37fb606449b4a5b10ba42235a5"}, - {file = "websockets-12.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:1e4b3f8ea6a9cfa8be8484c9221ec0257508e3a1ec43c36acdefb2a9c3b00aa2"}, - {file = "websockets-12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9fdf06fd06c32205a07e47328ab49c40fc1407cdec801d698a7c41167ea45113"}, - {file = "websockets-12.0-cp312-cp312-win32.whl", hash = "sha256:baa386875b70cbd81798fa9f71be689c1bf484f65fd6fb08d051a0ee4e79924d"}, - {file = "websockets-12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae0a5da8f35a5be197f328d4727dbcfafa53d1824fac3d96cdd3a642fe09394f"}, - {file = "websockets-12.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5f6ffe2c6598f7f7207eef9a1228b6f5c818f9f4d53ee920aacd35cec8110438"}, - {file = "websockets-12.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9edf3fc590cc2ec20dc9d7a45108b5bbaf21c0d89f9fd3fd1685e223771dc0b2"}, - {file = "websockets-12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8572132c7be52632201a35f5e08348137f658e5ffd21f51f94572ca6c05ea81d"}, - {file = "websockets-12.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:604428d1b87edbf02b233e2c207d7d528460fa978f9e391bd8aaf9c8311de137"}, - {file = "websockets-12.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a9d160fd080c6285e202327aba140fc9a0d910b09e423afff4ae5cbbf1c7205"}, - {file = "websockets-12.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87b4aafed34653e465eb77b7c93ef058516cb5acf3eb21e42f33928616172def"}, - {file = "websockets-12.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b2ee7288b85959797970114deae81ab41b731f19ebcd3bd499ae9ca0e3f1d2c8"}, - {file = "websockets-12.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7fa3d25e81bfe6a89718e9791128398a50dec6d57faf23770787ff441d851967"}, - {file = "websockets-12.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a571f035a47212288e3b3519944f6bf4ac7bc7553243e41eac50dd48552b6df7"}, - {file = "websockets-12.0-cp38-cp38-win32.whl", hash = "sha256:3c6cc1360c10c17463aadd29dd3af332d4a1adaa8796f6b0e9f9df1fdb0bad62"}, - {file = "websockets-12.0-cp38-cp38-win_amd64.whl", hash = "sha256:1bf386089178ea69d720f8db6199a0504a406209a0fc23e603b27b300fdd6892"}, - {file = "websockets-12.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ab3d732ad50a4fbd04a4490ef08acd0517b6ae6b77eb967251f4c263011a990d"}, - {file = "websockets-12.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1d9697f3337a89691e3bd8dc56dea45a6f6d975f92e7d5f773bc715c15dde28"}, - {file = "websockets-12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1df2fbd2c8a98d38a66f5238484405b8d1d16f929bb7a33ed73e4801222a6f53"}, - {file = "websockets-12.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23509452b3bc38e3a057382c2e941d5ac2e01e251acce7adc74011d7d8de434c"}, - {file = "websockets-12.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e5fc14ec6ea568200ea4ef46545073da81900a2b67b3e666f04adf53ad452ec"}, - {file = "websockets-12.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46e71dbbd12850224243f5d2aeec90f0aaa0f2dde5aeeb8fc8df21e04d99eff9"}, - {file = "websockets-12.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b81f90dcc6c85a9b7f29873beb56c94c85d6f0dac2ea8b60d995bd18bf3e2aae"}, - {file = "websockets-12.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a02413bc474feda2849c59ed2dfb2cddb4cd3d2f03a2fedec51d6e959d9b608b"}, - {file = "websockets-12.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bbe6013f9f791944ed31ca08b077e26249309639313fff132bfbf3ba105673b9"}, - {file = "websockets-12.0-cp39-cp39-win32.whl", hash = "sha256:cbe83a6bbdf207ff0541de01e11904827540aa069293696dd528a6640bd6a5f6"}, - {file = "websockets-12.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc4e7fa5414512b481a2483775a8e8be7803a35b30ca805afa4998a84f9fd9e8"}, - {file = "websockets-12.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:248d8e2446e13c1d4326e0a6a4e9629cb13a11195051a73acf414812700badbd"}, - {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44069528d45a933997a6fef143030d8ca8042f0dfaad753e2906398290e2870"}, - {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4e37d36f0d19f0a4413d3e18c0d03d0c268ada2061868c1e6f5ab1a6d575077"}, - {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d829f975fc2e527a3ef2f9c8f25e553eb7bc779c6665e8e1d52aa22800bb38b"}, - {file = "websockets-12.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2c71bd45a777433dd9113847af751aae36e448bc6b8c361a566cb043eda6ec30"}, - {file = "websockets-12.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0bee75f400895aef54157b36ed6d3b308fcab62e5260703add87f44cee9c82a6"}, - {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:423fc1ed29f7512fceb727e2d2aecb952c46aa34895e9ed96071821309951123"}, - {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27a5e9964ef509016759f2ef3f2c1e13f403725a5e6a1775555994966a66e931"}, - {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3181df4583c4d3994d31fb235dc681d2aaad744fbdbf94c4802485ececdecf2"}, - {file = "websockets-12.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:b067cb952ce8bf40115f6c19f478dc71c5e719b7fbaa511359795dfd9d1a6468"}, - {file = "websockets-12.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:00700340c6c7ab788f176d118775202aadea7602c5cc6be6ae127761c16d6b0b"}, - {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e469d01137942849cff40517c97a30a93ae79917752b34029f0ec72df6b46399"}, - {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffefa1374cd508d633646d51a8e9277763a9b78ae71324183693959cf94635a7"}, - {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba0cab91b3956dfa9f512147860783a1829a8d905ee218a9837c18f683239611"}, - {file = "websockets-12.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2cb388a5bfb56df4d9a406783b7f9dbefb888c09b71629351cc6b036e9259370"}, - {file = "websockets-12.0-py3-none-any.whl", hash = "sha256:dc284bbc8d7c78a6c69e0c7325ab46ee5e40bb4d50e494d8131a07ef47500e9e"}, - {file = "websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b"}, -] - -[[package]] -name = "werkzeug" -version = "3.0.3" -description = "The comprehensive WSGI web application library." -optional = false -python-versions = ">=3.8" -files = [ - {file = "werkzeug-3.0.3-py3-none-any.whl", hash = "sha256:fc9645dc43e03e4d630d23143a04a7f947a9a3b5727cd535fdfe155a17cc48c8"}, - {file = "werkzeug-3.0.3.tar.gz", hash = "sha256:097e5bfda9f0aba8da6b8545146def481d06aa7d3266e7448e2cccf67dd8bd18"}, -] - -[package.dependencies] -MarkupSafe = ">=2.1.1" - -[package.extras] -watchdog = ["watchdog (>=2.3)"] - -[[package]] -name = "zope-event" -version = "5.0" -description = "Very basic event publishing system" -optional = false -python-versions = ">=3.7" -files = [ - {file = "zope.event-5.0-py3-none-any.whl", hash = "sha256:2832e95014f4db26c47a13fdaef84cef2f4df37e66b59d8f1f4a8f319a632c26"}, - {file = "zope.event-5.0.tar.gz", hash = "sha256:bac440d8d9891b4068e2b5a2c5e2c9765a9df762944bda6955f96bb9b91e67cd"}, -] - -[package.dependencies] -setuptools = "*" - -[package.extras] -docs = ["Sphinx"] -test = ["zope.testrunner"] - -[[package]] -name = "zope-interface" -version = "6.1" -description = "Interfaces for Python" -optional = false -python-versions = ">=3.7" -files = [ - {file = "zope.interface-6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:43b576c34ef0c1f5a4981163b551a8781896f2a37f71b8655fd20b5af0386abb"}, - {file = "zope.interface-6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:67be3ca75012c6e9b109860820a8b6c9a84bfb036fbd1076246b98e56951ca92"}, - {file = "zope.interface-6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b9bc671626281f6045ad61d93a60f52fd5e8209b1610972cf0ef1bbe6d808e3"}, - {file = "zope.interface-6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bbe81def9cf3e46f16ce01d9bfd8bea595e06505e51b7baf45115c77352675fd"}, - {file = "zope.interface-6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dc998f6de015723196a904045e5a2217f3590b62ea31990672e31fbc5370b41"}, - {file = "zope.interface-6.1-cp310-cp310-win_amd64.whl", hash = "sha256:239a4a08525c080ff833560171d23b249f7f4d17fcbf9316ef4159f44997616f"}, - {file = "zope.interface-6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9ffdaa5290422ac0f1688cb8adb1b94ca56cee3ad11f29f2ae301df8aecba7d1"}, - {file = "zope.interface-6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34c15ca9248f2e095ef2e93af2d633358c5f048c49fbfddf5fdfc47d5e263736"}, - {file = "zope.interface-6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b012d023b4fb59183909b45d7f97fb493ef7a46d2838a5e716e3155081894605"}, - {file = "zope.interface-6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:97806e9ca3651588c1baaebb8d0c5ee3db95430b612db354c199b57378312ee8"}, - {file = "zope.interface-6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fddbab55a2473f1d3b8833ec6b7ac31e8211b0aa608df5ab09ce07f3727326de"}, - {file = "zope.interface-6.1-cp311-cp311-win_amd64.whl", hash = "sha256:a0da79117952a9a41253696ed3e8b560a425197d4e41634a23b1507efe3273f1"}, - {file = "zope.interface-6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8bb9c990ca9027b4214fa543fd4025818dc95f8b7abce79d61dc8a2112b561a"}, - {file = "zope.interface-6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b51b64432eed4c0744241e9ce5c70dcfecac866dff720e746d0a9c82f371dfa7"}, - {file = "zope.interface-6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa6fd016e9644406d0a61313e50348c706e911dca29736a3266fc9e28ec4ca6d"}, - {file = "zope.interface-6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c8cf55261e15590065039696607f6c9c1aeda700ceee40c70478552d323b3ff"}, - {file = "zope.interface-6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e30506bcb03de8983f78884807e4fd95d8db6e65b69257eea05d13d519b83ac0"}, - {file = "zope.interface-6.1-cp312-cp312-win_amd64.whl", hash = "sha256:e33e86fd65f369f10608b08729c8f1c92ec7e0e485964670b4d2633a4812d36b"}, - {file = "zope.interface-6.1-cp37-cp37m-macosx_11_0_x86_64.whl", hash = "sha256:2f8d89721834524a813f37fa174bac074ec3d179858e4ad1b7efd4401f8ac45d"}, - {file = "zope.interface-6.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13b7d0f2a67eb83c385880489dbb80145e9d344427b4262c49fbf2581677c11c"}, - {file = "zope.interface-6.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef43ee91c193f827e49599e824385ec7c7f3cd152d74cb1dfe02cb135f264d83"}, - {file = "zope.interface-6.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e441e8b7d587af0414d25e8d05e27040d78581388eed4c54c30c0c91aad3a379"}, - {file = "zope.interface-6.1-cp37-cp37m-win_amd64.whl", hash = "sha256:f89b28772fc2562ed9ad871c865f5320ef761a7fcc188a935e21fe8b31a38ca9"}, - {file = "zope.interface-6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:70d2cef1bf529bff41559be2de9d44d47b002f65e17f43c73ddefc92f32bf00f"}, - {file = "zope.interface-6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ad54ed57bdfa3254d23ae04a4b1ce405954969c1b0550cc2d1d2990e8b439de1"}, - {file = "zope.interface-6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef467d86d3cfde8b39ea1b35090208b0447caaabd38405420830f7fd85fbdd56"}, - {file = "zope.interface-6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6af47f10cfc54c2ba2d825220f180cc1e2d4914d783d6fc0cd93d43d7bc1c78b"}, - {file = "zope.interface-6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9559138690e1bd4ea6cd0954d22d1e9251e8025ce9ede5d0af0ceae4a401e43"}, - {file = "zope.interface-6.1-cp38-cp38-win_amd64.whl", hash = "sha256:964a7af27379ff4357dad1256d9f215047e70e93009e532d36dcb8909036033d"}, - {file = "zope.interface-6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:387545206c56b0315fbadb0431d5129c797f92dc59e276b3ce82db07ac1c6179"}, - {file = "zope.interface-6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:57d0a8ce40ce440f96a2c77824ee94bf0d0925e6089df7366c2272ccefcb7941"}, - {file = "zope.interface-6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ebc4d34e7620c4f0da7bf162c81978fce0ea820e4fa1e8fc40ee763839805f3"}, - {file = "zope.interface-6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a804abc126b33824a44a7aa94f06cd211a18bbf31898ba04bd0924fbe9d282d"}, - {file = "zope.interface-6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f294a15f7723fc0d3b40701ca9b446133ec713eafc1cc6afa7b3d98666ee1ac"}, - {file = "zope.interface-6.1-cp39-cp39-win_amd64.whl", hash = "sha256:a41f87bb93b8048fe866fa9e3d0c51e27fe55149035dcf5f43da4b56732c0a40"}, - {file = "zope.interface-6.1.tar.gz", hash = "sha256:2fdc7ccbd6eb6b7df5353012fbed6c3c5d04ceaca0038f75e601060e95345309"}, -] - -[package.dependencies] -setuptools = "*" - -[package.extras] -docs = ["Sphinx", "repoze.sphinx.autointerface", "sphinx-rtd-theme"] -test = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] -testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] - -[metadata] -lock-version = "2.0" -python-versions = ">=3.10,<4.0" -content-hash = "0dfd0c31320434bf52aa454d7981faf8cbb162d23276bb05519ade9e8b6d6194" diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index c02b8f276b..140f727de3 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,79 +1,78 @@ -[tool.poetry] +[project] name = "machine-learning" -version = "1.125.1" +version = "1.129.0" description = "" -authors = ["Hau Tran "] +authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }] +requires-python = ">=3.10,<4.0" readme = "README.md" -packages = [{include = "app"}] +dependencies = [ + "aiocache>=0.12.1,<1.0", + "fastapi>=0.95.2,<1.0", + "ftfy>=6.1.1", + "gunicorn>=21.1.0", + "huggingface-hub>=0.20.1,<1.0", + "insightface>=0.7.3,<1.0", + "opencv-python-headless>=4.7.0.72,<5.0", + "orjson>=3.9.5", + "pillow>=9.5.0,<11.0", + "pydantic>=2.0.0,<3", + "pydantic-settings>=2.5.2,<3", + "python-multipart>=0.0.6,<1.0", + "rich>=13.4.2", + "tokenizers>=0.15.0,<1.0", + "uvicorn[standard]>=0.22.0,<1.0", +] -[tool.poetry.dependencies] -python = ">=3.10,<4.0" -insightface = ">=0.7.3,<1.0" -opencv-python-headless = ">=4.7.0.72,<5.0" -pillow = ">=9.5.0,<11.0" -fastapi = ">=0.95.2,<1.0" -uvicorn = {extras = ["standard"], version = ">=0.22.0,<1.0"} -pydantic = "^2.0.0" -pydantic-settings = "^2.5.2" -aiocache = ">=0.12.1,<1.0" -rich = ">=13.4.2" -ftfy = ">=6.1.1" -python-multipart = ">=0.0.6,<1.0" -orjson = ">=3.9.5" -gunicorn = ">=21.1.0" -huggingface-hub = ">=0.20.1,<1.0" -tokenizers = ">=0.15.0,<1.0" +[dependency-groups] +test = [ + "httpx>=0.24.1", + "pytest>=7.3.1", + "pytest-asyncio>=0.21.0", + "pytest-cov>=4.1.0", + "pytest-mock>=3.11.1", +] +types = [ + "types-pyyaml>=6.0.12.20241230", + "types-requests>=2.32.0.20250306", + "types-setuptools>=75.8.2.20250305", + "types-simplejson>=3.20.0.20250218", + "types-ujson>=5.10.0.20240515", +] +lint = [ + "black>=23.3.0", + "mypy>=1.3.0", + "ruff>=0.0.272", + { include-group = "types" }, +] +dev = ["locust>=2.15.1", { include-group = "test" }, { include-group = "lint" }] -[tool.poetry.group.dev.dependencies] -mypy = ">=1.3.0" -black = ">=23.3.0" -pytest = ">=7.3.1" -locust = ">=2.15.1" -httpx = ">=0.24.1" -pytest-asyncio = ">=0.21.0" -pytest-cov = ">=4.1.0" -ruff = ">=0.0.272" -pytest-mock = ">=3.11.1" +[project.optional-dependencies] +cpu = ["onnxruntime>=1.15.0,<2"] +cuda = ["onnxruntime-gpu>=1.17.0,<2"] +openvino = ["onnxruntime-openvino>=1.17.1,<1.19.0"] +armnn = ["onnxruntime>=1.15.0,<2"] +rknn = ["onnxruntime>=1.15.0,<2", "rknn-toolkit-lite2>=2.3.0,<3"] -[tool.poetry.group.cpu] -optional = true +[tool.uv] +compile-bytecode = true -[tool.poetry.group.cpu.dependencies] -onnxruntime = "^1.15.0" - -[tool.poetry.group.cuda] -optional = true - -[tool.poetry.group.cuda.dependencies] -onnxruntime-gpu = {version = "^1.17.0", source = "cuda12"} - -[tool.poetry.group.openvino] -optional = true - -[tool.poetry.group.openvino.dependencies] -onnxruntime-openvino = ">=1.17.1,<1.19.0" - -[tool.poetry.group.armnn] -optional = true - -[tool.poetry.group.armnn.dependencies] -onnxruntime = "^1.15.0" - -[tool.poetry.group.rknn] -optional = true - -[tool.poetry.group.rknn.dependencies] -rknn-toolkit-lite2 = "^2.3.0" -onnxruntime = "^1.15.0" - -[[tool.poetry.source]] +[[tool.uv.index]] name = "cuda12" url = "https://aiinfra.pkgs.visualstudio.com/PublicPackages/_packaging/onnxruntime-cuda-12/pypi/simple/" -priority = "explicit" +explicit = true + +[tool.uv.sources] +onnxruntime-gpu = { index = "cuda12" } + +[tool.hatch.build.targets.sdist] +include = ["app"] + +[tool.hatch.build.targets.wheel] +include = ["app"] [build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" +requires = ["hatchling"] +build-backend = "hatchling.build" [tool.mypy] python_version = "3.11" diff --git a/machine-learning/start.sh b/machine-learning/start.sh index 552cca1f5e..d2f5b94dc3 100755 --- a/machine-learning/start.sh +++ b/machine-learning/start.sh @@ -1,5 +1,7 @@ #!/usr/bin/env sh +echo "Initializing Immich ML $IMMICH_SOURCE_REF" + lib_path="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.2" # mimalloc seems to increase memory usage dramatically with openvino, need to investigate if ! [ "$DEVICE" = "openvino" ]; then diff --git a/machine-learning/uv.lock b/machine-learning/uv.lock new file mode 100644 index 0000000000..32ac09c7c6 --- /dev/null +++ b/machine-learning/uv.lock @@ -0,0 +1,2725 @@ +version = 1 +revision = 1 +requires-python = ">=3.10, <4.0" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'darwin'", + "python_full_version == '3.13.*' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and sys_platform == 'darwin'", + "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'linux')", + "(python_full_version == '3.13.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'linux')", + "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", +] + +[[package]] +name = "aiocache" +version = "0.12.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/64/b945b8025a9d1e6e2138845f4022165d3b337f55f50984fbc6a4c0a1e355/aiocache-0.12.3.tar.gz", hash = "sha256:f528b27bf4d436b497a1d0d1a8f59a542c153ab1e37c3621713cb376d44c4713", size = 132196 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/d7/15d67e05b235d1ed8c3ce61688fe4d84130e72af1657acadfaac3479f4cf/aiocache-0.12.3-py2.py3-none-any.whl", hash = "sha256:889086fc24710f431937b87ad3720a289f7fc31c4fd8b68e9f918b9bacd8270d", size = 28199 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/f6/c486cedb4f75147232f32ec4c97026714cfef7c7e247a1f0427bc5489f66/albumentations-1.3.1-py3-none-any.whl", hash = "sha256:6b641d13733181d9ecdc29550e6ad580d1bfa9d25e2213a66940062f25e291bd", size = 125706 }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/b8/7333d87d5f03247215d86a86362fd3e324111788c6cdd8d2e6196a6ba833/anyio-4.2.0.tar.gz", hash = "sha256:e1875bb4b4e2de1669f4bc7869b6d3f54231cdced71605e6e64c9be77e3be50f", size = 158770 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/cd/d6d9bb1dadf73e7af02d18225cbd2c93f8552e13130484f1c8dcfece292b/anyio-4.2.0-py3-none-any.whl", hash = "sha256:745843b39e829e108e518c489b31dc757de7d2131d53fac32bd8df268227bfee", size = 85481 }, +] + +[[package]] +name = "black" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/3b/4ba3f93ac8d90410423fdd31d7541ada9bcee1df32fb90d26de41ed40e1d/black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32", size = 1629419 }, + { url = "https://files.pythonhosted.org/packages/b4/02/0bde0485146a8a5e694daed47561785e8b77a0466ccc1f3e485d5ef2925e/black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da", size = 1461080 }, + { url = "https://files.pythonhosted.org/packages/52/0e/abdf75183c830eaca7589144ff96d49bce73d7ec6ad12ef62185cc0f79a2/black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7", size = 1766886 }, + { url = "https://files.pythonhosted.org/packages/dc/a6/97d8bb65b1d8a41f8a6736222ba0a334db7b7b77b8023ab4568288f23973/black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9", size = 1419404 }, + { url = "https://files.pythonhosted.org/packages/7e/4f/87f596aca05c3ce5b94b8663dbfe242a12843caaa82dd3f85f1ffdc3f177/black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0", size = 1614372 }, + { url = "https://files.pythonhosted.org/packages/e7/d0/2c34c36190b741c59c901e56ab7f6e54dad8df05a6272a9747ecef7c6036/black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299", size = 1442865 }, + { url = "https://files.pythonhosted.org/packages/21/d4/7518c72262468430ead45cf22bd86c883a6448b9eb43672765d69a8f1248/black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096", size = 1749699 }, + { url = "https://files.pythonhosted.org/packages/58/db/4f5beb989b547f79096e035c4981ceb36ac2b552d0ac5f2620e941501c99/black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2", size = 1428028 }, + { url = "https://files.pythonhosted.org/packages/83/71/3fe4741df7adf015ad8dfa082dd36c94ca86bb21f25608eb247b4afb15b2/black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", size = 1650988 }, + { url = "https://files.pythonhosted.org/packages/13/f3/89aac8a83d73937ccd39bbe8fc6ac8860c11cfa0af5b1c96d081facac844/black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", size = 1453985 }, + { url = "https://files.pythonhosted.org/packages/6f/22/b99efca33f1f3a1d2552c714b1e1b5ae92efac6c43e790ad539a163d1754/black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", size = 1783816 }, + { url = "https://files.pythonhosted.org/packages/18/7e/a27c3ad3822b6f2e0e00d63d58ff6299a99a5b3aee69fa77cd4b0076b261/black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", size = 1440860 }, + { url = "https://files.pythonhosted.org/packages/98/87/0edf98916640efa5d0696e1abb0a8357b52e69e82322628f25bf14d263d1/black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", size = 1650673 }, + { url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190 }, + { url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926 }, + { url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613 }, + { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646 }, +] + +[[package]] +name = "blinker" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/13/6df5fc090ff4e5d246baf1f45fe9e5623aa8565757dfa5bd243f6a545f9e/blinker-1.7.0.tar.gz", hash = "sha256:e6820ff6fa4e4d1d8e2747c2283749c3f547e4fee112b98555cdcdae32996182", size = 28134 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/2a/7f3714cbc6356a0efec525ce7a0613d581072ed6eb53eb7b9754f33db807/blinker-1.7.0-py3-none-any.whl", hash = "sha256:c3f865d4d54db7abc53758a01601cf343fe55b84c1de4e3fa910e420b438d5b9", size = 13068 }, +] + +[[package]] +name = "brotli" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/c2/f9e977608bdf958650638c3f1e28f85a1b075f075ebbe77db8555463787b/Brotli-1.1.0.tar.gz", hash = "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724", size = 7372270 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/3a/dbf4fb970c1019a57b5e492e1e0eae745d32e59ba4d6161ab5422b08eefe/Brotli-1.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e1140c64812cb9b06c922e77f1c26a75ec5e3f0fb2bf92cc8c58720dec276752", size = 873045 }, + { url = "https://files.pythonhosted.org/packages/dd/11/afc14026ea7f44bd6eb9316d800d439d092c8d508752055ce8d03086079a/Brotli-1.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c8fd5270e906eef71d4a8d19b7c6a43760c6abcfcc10c9101d14eb2357418de9", size = 446218 }, + { url = "https://files.pythonhosted.org/packages/36/83/7545a6e7729db43cb36c4287ae388d6885c85a86dd251768a47015dfde32/Brotli-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ae56aca0402a0f9a3431cddda62ad71666ca9d4dc3a10a142b9dce2e3c0cda3", size = 2903872 }, + { url = "https://files.pythonhosted.org/packages/32/23/35331c4d9391fcc0f29fd9bec2c76e4b4eeab769afbc4b11dd2e1098fb13/Brotli-1.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:43ce1b9935bfa1ede40028054d7f48b5469cd02733a365eec8a329ffd342915d", size = 2941254 }, + { url = "https://files.pythonhosted.org/packages/3b/24/1671acb450c902edb64bd765d73603797c6c7280a9ada85a195f6b78c6e5/Brotli-1.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7c4855522edb2e6ae7fdb58e07c3ba9111e7621a8956f481c68d5d979c93032e", size = 2857293 }, + { url = "https://files.pythonhosted.org/packages/d5/00/40f760cc27007912b327fe15bf6bfd8eaecbe451687f72a8abc587d503b3/Brotli-1.1.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:38025d9f30cf4634f8309c6874ef871b841eb3c347e90b0851f63d1ded5212da", size = 3002385 }, + { url = "https://files.pythonhosted.org/packages/b8/cb/8aaa83f7a4caa131757668c0fb0c4b6384b09ffa77f2fba9570d87ab587d/Brotli-1.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e6a904cb26bfefc2f0a6f240bdf5233be78cd2488900a2f846f3c3ac8489ab80", size = 2911104 }, + { url = "https://files.pythonhosted.org/packages/bc/c4/65456561d89d3c49f46b7fbeb8fe6e449f13bdc8ea7791832c5d476b2faf/Brotli-1.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a37b8f0391212d29b3a91a799c8e4a2855e0576911cdfb2515487e30e322253d", size = 2809981 }, + { url = "https://files.pythonhosted.org/packages/05/1b/cf49528437bae28abce5f6e059f0d0be6fecdcc1d3e33e7c54b3ca498425/Brotli-1.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e84799f09591700a4154154cab9787452925578841a94321d5ee8fb9a9a328f0", size = 2935297 }, + { url = "https://files.pythonhosted.org/packages/81/ff/190d4af610680bf0c5a09eb5d1eac6e99c7c8e216440f9c7cfd42b7adab5/Brotli-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f66b5337fa213f1da0d9000bc8dc0cb5b896b726eefd9c6046f699b169c41b9e", size = 2930735 }, + { url = "https://files.pythonhosted.org/packages/80/7d/f1abbc0c98f6e09abd3cad63ec34af17abc4c44f308a7a539010f79aae7a/Brotli-1.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5dab0844f2cf82be357a0eb11a9087f70c5430b2c241493fc122bb6f2bb0917c", size = 2933107 }, + { url = "https://files.pythonhosted.org/packages/34/ce/5a5020ba48f2b5a4ad1c0522d095ad5847a0be508e7d7569c8630ce25062/Brotli-1.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e4fe605b917c70283db7dfe5ada75e04561479075761a0b3866c081d035b01c1", size = 2845400 }, + { url = "https://files.pythonhosted.org/packages/44/89/fa2c4355ab1eecf3994e5a0a7f5492c6ff81dfcb5f9ba7859bd534bb5c1a/Brotli-1.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1e9a65b5736232e7a7f91ff3d02277f11d339bf34099a56cdab6a8b3410a02b2", size = 3031985 }, + { url = "https://files.pythonhosted.org/packages/af/a4/79196b4a1674143d19dca400866b1a4d1a089040df7b93b88ebae81f3447/Brotli-1.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:58d4b711689366d4a03ac7957ab8c28890415e267f9b6589969e74b6e42225ec", size = 2927099 }, + { url = "https://files.pythonhosted.org/packages/e9/54/1c0278556a097f9651e657b873ab08f01b9a9ae4cac128ceb66427d7cd20/Brotli-1.1.0-cp310-cp310-win32.whl", hash = "sha256:be36e3d172dc816333f33520154d708a2657ea63762ec16b62ece02ab5e4daf2", size = 333172 }, + { url = "https://files.pythonhosted.org/packages/f7/65/b785722e941193fd8b571afd9edbec2a9b838ddec4375d8af33a50b8dab9/Brotli-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c6244521dda65ea562d5a69b9a26120769b7a9fb3db2fe9545935ed6735b128", size = 357255 }, + { url = "https://files.pythonhosted.org/packages/96/12/ad41e7fadd5db55459c4c401842b47f7fee51068f86dd2894dd0dcfc2d2a/Brotli-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc", size = 873068 }, + { url = "https://files.pythonhosted.org/packages/95/4e/5afab7b2b4b61a84e9c75b17814198ce515343a44e2ed4488fac314cd0a9/Brotli-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c8146669223164fc87a7e3de9f81e9423c67a79d6b3447994dfb9c95da16e2d6", size = 446244 }, + { url = "https://files.pythonhosted.org/packages/9d/e6/f305eb61fb9a8580c525478a4a34c5ae1a9bcb12c3aee619114940bc513d/Brotli-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30924eb4c57903d5a7526b08ef4a584acc22ab1ffa085faceb521521d2de32dd", size = 2906500 }, + { url = "https://files.pythonhosted.org/packages/3e/4f/af6846cfbc1550a3024e5d3775ede1e00474c40882c7bf5b37a43ca35e91/Brotli-1.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ceb64bbc6eac5a140ca649003756940f8d6a7c444a68af170b3187623b43bebf", size = 2943950 }, + { url = "https://files.pythonhosted.org/packages/b3/e7/ca2993c7682d8629b62630ebf0d1f3bb3d579e667ce8e7ca03a0a0576a2d/Brotli-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a469274ad18dc0e4d316eefa616d1d0c2ff9da369af19fa6f3daa4f09671fd61", size = 2918527 }, + { url = "https://files.pythonhosted.org/packages/b3/96/da98e7bedc4c51104d29cc61e5f449a502dd3dbc211944546a4cc65500d3/Brotli-1.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:524f35912131cc2cabb00edfd8d573b07f2d9f21fa824bd3fb19725a9cf06327", size = 2845489 }, + { url = "https://files.pythonhosted.org/packages/e8/ef/ccbc16947d6ce943a7f57e1a40596c75859eeb6d279c6994eddd69615265/Brotli-1.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5b3cc074004d968722f51e550b41a27be656ec48f8afaeeb45ebf65b561481dd", size = 2914080 }, + { url = "https://files.pythonhosted.org/packages/80/d6/0bd38d758d1afa62a5524172f0b18626bb2392d717ff94806f741fcd5ee9/Brotli-1.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9", size = 2813051 }, + { url = "https://files.pythonhosted.org/packages/14/56/48859dd5d129d7519e001f06dcfbb6e2cf6db92b2702c0c2ce7d97e086c1/Brotli-1.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265", size = 2938172 }, + { url = "https://files.pythonhosted.org/packages/3d/77/a236d5f8cd9e9f4348da5acc75ab032ab1ab2c03cc8f430d24eea2672888/Brotli-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8", size = 2933023 }, + { url = "https://files.pythonhosted.org/packages/f1/87/3b283efc0f5cb35f7f84c0c240b1e1a1003a5e47141a4881bf87c86d0ce2/Brotli-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c247dd99d39e0338a604f8c2b3bc7061d5c2e9e2ac7ba9cc1be5a69cb6cd832f", size = 2935871 }, + { url = "https://files.pythonhosted.org/packages/f3/eb/2be4cc3e2141dc1a43ad4ca1875a72088229de38c68e842746b342667b2a/Brotli-1.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1b2c248cd517c222d89e74669a4adfa5577e06ab68771a529060cf5a156e9757", size = 2847784 }, + { url = "https://files.pythonhosted.org/packages/66/13/b58ddebfd35edde572ccefe6890cf7c493f0c319aad2a5badee134b4d8ec/Brotli-1.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2a24c50840d89ded6c9a8fdc7b6ed3692ed4e86f1c4a4a938e1e92def92933e0", size = 3034905 }, + { url = "https://files.pythonhosted.org/packages/84/9c/bc96b6c7db824998a49ed3b38e441a2cae9234da6fa11f6ed17e8cf4f147/Brotli-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f31859074d57b4639318523d6ffdca586ace54271a73ad23ad021acd807eb14b", size = 2929467 }, + { url = "https://files.pythonhosted.org/packages/e7/71/8f161dee223c7ff7fea9d44893fba953ce97cf2c3c33f78ba260a91bcff5/Brotli-1.1.0-cp311-cp311-win32.whl", hash = "sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50", size = 333169 }, + { url = "https://files.pythonhosted.org/packages/02/8a/fece0ee1057643cb2a5bbf59682de13f1725f8482b2c057d4e799d7ade75/Brotli-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1", size = 357253 }, + { url = "https://files.pythonhosted.org/packages/5c/d0/5373ae13b93fe00095a58efcbce837fd470ca39f703a235d2a999baadfbc/Brotli-1.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:32d95b80260d79926f5fab3c41701dbb818fde1c9da590e77e571eefd14abe28", size = 815693 }, + { url = "https://files.pythonhosted.org/packages/8e/48/f6e1cdf86751300c288c1459724bfa6917a80e30dbfc326f92cea5d3683a/Brotli-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b760c65308ff1e462f65d69c12e4ae085cff3b332d894637f6273a12a482d09f", size = 422489 }, + { url = "https://files.pythonhosted.org/packages/06/88/564958cedce636d0f1bed313381dfc4b4e3d3f6015a63dae6146e1b8c65c/Brotli-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409", size = 873081 }, + { url = "https://files.pythonhosted.org/packages/58/79/b7026a8bb65da9a6bb7d14329fd2bd48d2b7f86d7329d5cc8ddc6a90526f/Brotli-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2", size = 446244 }, + { url = "https://files.pythonhosted.org/packages/e5/18/c18c32ecea41b6c0004e15606e274006366fe19436b6adccc1ae7b2e50c2/Brotli-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451", size = 2906505 }, + { url = "https://files.pythonhosted.org/packages/08/c8/69ec0496b1ada7569b62d85893d928e865df29b90736558d6c98c2031208/Brotli-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f4bf76817c14aa98cc6697ac02f3972cb8c3da93e9ef16b9c66573a68014f91", size = 2944152 }, + { url = "https://files.pythonhosted.org/packages/ab/fb/0517cea182219d6768113a38167ef6d4eb157a033178cc938033a552ed6d/Brotli-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0c5516f0aed654134a2fc936325cc2e642f8a0e096d075209672eb321cff408", size = 2919252 }, + { url = "https://files.pythonhosted.org/packages/c7/53/73a3431662e33ae61a5c80b1b9d2d18f58dfa910ae8dd696e57d39f1a2f5/Brotli-1.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c3020404e0b5eefd7c9485ccf8393cfb75ec38ce75586e046573c9dc29967a0", size = 2845955 }, + { url = "https://files.pythonhosted.org/packages/55/ac/bd280708d9c5ebdbf9de01459e625a3e3803cce0784f47d633562cf40e83/Brotli-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4ed11165dd45ce798d99a136808a794a748d5dc38511303239d4e2363c0695dc", size = 2914304 }, + { url = "https://files.pythonhosted.org/packages/76/58/5c391b41ecfc4527d2cc3350719b02e87cb424ef8ba2023fb662f9bf743c/Brotli-1.1.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180", size = 2814452 }, + { url = "https://files.pythonhosted.org/packages/c7/4e/91b8256dfe99c407f174924b65a01f5305e303f486cc7a2e8a5d43c8bec3/Brotli-1.1.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248", size = 2938751 }, + { url = "https://files.pythonhosted.org/packages/5a/a6/e2a39a5d3b412938362bbbeba5af904092bf3f95b867b4a3eb856104074e/Brotli-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966", size = 2933757 }, + { url = "https://files.pythonhosted.org/packages/13/f0/358354786280a509482e0e77c1a5459e439766597d280f28cb097642fc26/Brotli-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87a3044c3a35055527ac75e419dfa9f4f3667a1e887ee80360589eb8c90aabb9", size = 2936146 }, + { url = "https://files.pythonhosted.org/packages/80/f7/daf538c1060d3a88266b80ecc1d1c98b79553b3f117a485653f17070ea2a/Brotli-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c5529b34c1c9d937168297f2c1fde7ebe9ebdd5e121297ff9c043bdb2ae3d6fb", size = 2848055 }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0eaa0585c4077d3c2d1edf322d8e97aabf317941d3a72d7b3ad8bce004b0/Brotli-1.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ca63e1890ede90b2e4454f9a65135a4d387a4585ff8282bb72964fab893f2111", size = 3035102 }, + { url = "https://files.pythonhosted.org/packages/d8/63/1c1585b2aa554fe6dbce30f0c18bdbc877fa9a1bf5ff17677d9cca0ac122/Brotli-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e79e6520141d792237c70bcd7a3b122d00f2613769ae0cb61c52e89fd3443839", size = 2930029 }, + { url = "https://files.pythonhosted.org/packages/5f/3b/4e3fd1893eb3bbfef8e5a80d4508bec17a57bb92d586c85c12d28666bb13/Brotli-1.1.0-cp312-cp312-win32.whl", hash = "sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0", size = 333276 }, + { url = "https://files.pythonhosted.org/packages/3d/d5/942051b45a9e883b5b6e98c041698b1eb2012d25e5948c58d6bf85b1bb43/Brotli-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951", size = 357255 }, + { url = "https://files.pythonhosted.org/packages/0a/9f/fb37bb8ffc52a8da37b1c03c459a8cd55df7a57bdccd8831d500e994a0ca/Brotli-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5", size = 815681 }, + { url = "https://files.pythonhosted.org/packages/06/b3/dbd332a988586fefb0aa49c779f59f47cae76855c2d00f450364bb574cac/Brotli-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8", size = 422475 }, + { url = "https://files.pythonhosted.org/packages/bb/80/6aaddc2f63dbcf2d93c2d204e49c11a9ec93a8c7c63261e2b4bd35198283/Brotli-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f", size = 2906173 }, + { url = "https://files.pythonhosted.org/packages/ea/1d/e6ca79c96ff5b641df6097d299347507d39a9604bde8915e76bf026d6c77/Brotli-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91d7cc2a76b5567591d12c01f019dd7afce6ba8cba6571187e21e2fc418ae648", size = 2943803 }, + { url = "https://files.pythonhosted.org/packages/ac/a3/d98d2472e0130b7dd3acdbb7f390d478123dbf62b7d32bda5c830a96116d/Brotli-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a93dde851926f4f2678e704fadeb39e16c35d8baebd5252c9fd94ce8ce68c4a0", size = 2918946 }, + { url = "https://files.pythonhosted.org/packages/c4/a5/c69e6d272aee3e1423ed005d8915a7eaa0384c7de503da987f2d224d0721/Brotli-1.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0db75f47be8b8abc8d9e31bc7aad0547ca26f24a54e6fd10231d623f183d089", size = 2845707 }, + { url = "https://files.pythonhosted.org/packages/58/9f/4149d38b52725afa39067350696c09526de0125ebfbaab5acc5af28b42ea/Brotli-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6967ced6730aed543b8673008b5a391c3b1076d834ca438bbd70635c73775368", size = 2936231 }, + { url = "https://files.pythonhosted.org/packages/5a/5a/145de884285611838a16bebfdb060c231c52b8f84dfbe52b852a15780386/Brotli-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7eedaa5d036d9336c95915035fb57422054014ebdeb6f3b42eac809928e40d0c", size = 2848157 }, + { url = "https://files.pythonhosted.org/packages/50/ae/408b6bfb8525dadebd3b3dd5b19d631da4f7d46420321db44cd99dcf2f2c/Brotli-1.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d487f5432bf35b60ed625d7e1b448e2dc855422e87469e3f450aa5552b0eb284", size = 3035122 }, + { url = "https://files.pythonhosted.org/packages/af/85/a94e5cfaa0ca449d8f91c3d6f78313ebf919a0dbd55a100c711c6e9655bc/Brotli-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:832436e59afb93e1836081a20f324cb185836c617659b07b129141a8426973c7", size = 2930206 }, + { url = "https://files.pythonhosted.org/packages/c2/f0/a61d9262cd01351df22e57ad7c34f66794709acab13f34be2675f45bf89d/Brotli-1.1.0-cp313-cp313-win32.whl", hash = "sha256:43395e90523f9c23a3d5bdf004733246fba087f2948f87ab28015f12359ca6a0", size = 333804 }, + { url = "https://files.pythonhosted.org/packages/7e/c1/ec214e9c94000d1c1974ec67ced1c970c148aa6b8d8373066123fc3dbf06/Brotli-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9011560a466d2eb3f5a6e4929cf4a09be405c64154e12df0dd72713f6500e32b", size = 358517 }, +] + +[[package]] +name = "certifi" +version = "2023.11.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/91/c89518dd4fe1f3a4e3f6ab7ff23cb00ef2e8c9adf99dacc618ad5e068e28/certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1", size = 163637 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/62/428ef076be88fa93716b576e4a01f919d25968913e817077a386fcbe4f42/certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474", size = 162530 }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191 }, + { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592 }, + { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024 }, + { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188 }, + { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571 }, + { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687 }, + { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211 }, + { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325 }, + { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784 }, + { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564 }, + { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804 }, + { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299 }, + { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264 }, + { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651 }, + { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 }, + { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200 }, + { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235 }, + { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721 }, + { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242 }, + { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999 }, + { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242 }, + { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604 }, + { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 }, + { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 }, + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/09/c1bc53dab74b1816a00d8d030de5bf98f724c52c1635e07681d312f20be8/charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", size = 104809 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/61/095a0aa1a84d1481998b534177c8566fdc50bb1233ea9a0478cd3cc075bd/charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3", size = 194219 }, + { url = "https://files.pythonhosted.org/packages/cc/94/f7cf5e5134175de79ad2059edf2adce18e0685ebdb9227ff0139975d0e93/charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", size = 122521 }, + { url = "https://files.pythonhosted.org/packages/46/6a/d5c26c41c49b546860cc1acabdddf48b0b3fb2685f4f5617ac59261b44ae/charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03", size = 120383 }, + { url = "https://files.pythonhosted.org/packages/b8/60/e2f67915a51be59d4539ed189eb0a2b0d292bf79270410746becb32bc2c3/charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d", size = 138223 }, + { url = "https://files.pythonhosted.org/packages/05/8c/eb854996d5fef5e4f33ad56927ad053d04dc820e4a3d39023f35cad72617/charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e", size = 148101 }, + { url = "https://files.pythonhosted.org/packages/f6/93/bb6cbeec3bf9da9b2eba458c15966658d1daa8b982c642f81c93ad9b40e1/charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6", size = 140699 }, + { url = "https://files.pythonhosted.org/packages/da/f1/3702ba2a7470666a62fd81c58a4c40be00670e5006a67f4d626e57f013ae/charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5", size = 142065 }, + { url = "https://files.pythonhosted.org/packages/3f/ba/3f5e7be00b215fa10e13d64b1f6237eb6ebea66676a41b2bcdd09fe74323/charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537", size = 144505 }, + { url = "https://files.pythonhosted.org/packages/33/c3/3b96a435c5109dd5b6adc8a59ba1d678b302a97938f032e3770cc84cd354/charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c", size = 139425 }, + { url = "https://files.pythonhosted.org/packages/43/05/3bf613e719efe68fb3a77f9c536a389f35b95d75424b96b426a47a45ef1d/charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12", size = 145287 }, + { url = "https://files.pythonhosted.org/packages/58/78/a0bc646900994df12e07b4ae5c713f2b3e5998f58b9d3720cce2aa45652f/charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f", size = 149929 }, + { url = "https://files.pythonhosted.org/packages/eb/5c/97d97248af4920bc68687d9c3b3c0f47c910e21a8ff80af4565a576bd2f0/charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269", size = 141605 }, + { url = "https://files.pythonhosted.org/packages/a8/31/47d018ef89f95b8aded95c589a77c072c55e94b50a41aa99c0a2008a45a4/charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519", size = 142646 }, + { url = "https://files.pythonhosted.org/packages/ae/d5/4fecf1d58bedb1340a50f165ba1c7ddc0400252d6832ff619c4568b36cc0/charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73", size = 92846 }, + { url = "https://files.pythonhosted.org/packages/a2/a0/4af29e22cb5942488cf45630cbdd7cefd908768e69bdd90280842e4e8529/charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09", size = 100343 }, + { url = "https://files.pythonhosted.org/packages/68/77/02839016f6fbbf808e8b38601df6e0e66c17bbab76dff4613f7511413597/charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", size = 191647 }, + { url = "https://files.pythonhosted.org/packages/3e/33/21a875a61057165e92227466e54ee076b73af1e21fe1b31f1e292251aa1e/charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", size = 121434 }, + { url = "https://files.pythonhosted.org/packages/dd/51/68b61b90b24ca35495956b718f35a9756ef7d3dd4b3c1508056fa98d1a1b/charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", size = 118979 }, + { url = "https://files.pythonhosted.org/packages/e4/a6/7ee57823d46331ddc37dd00749c95b0edec2c79b15fc0d6e6efb532e89ac/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", size = 136582 }, + { url = "https://files.pythonhosted.org/packages/74/f1/0d9fe69ac441467b737ba7f48c68241487df2f4522dd7246d9426e7c690e/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", size = 146645 }, + { url = "https://files.pythonhosted.org/packages/05/31/e1f51c76db7be1d4aef220d29fbfa5dbb4a99165d9833dcbf166753b6dc0/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", size = 139398 }, + { url = "https://files.pythonhosted.org/packages/40/26/f35951c45070edc957ba40a5b1db3cf60a9dbb1b350c2d5bef03e01e61de/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", size = 140273 }, + { url = "https://files.pythonhosted.org/packages/07/07/7e554f2bbce3295e191f7e653ff15d55309a9ca40d0362fcdab36f01063c/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", size = 142577 }, + { url = "https://files.pythonhosted.org/packages/d8/b5/eb705c313100defa57da79277d9207dc8d8e45931035862fa64b625bfead/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", size = 137747 }, + { url = "https://files.pythonhosted.org/packages/19/28/573147271fd041d351b438a5665be8223f1dd92f273713cb882ddafe214c/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", size = 143375 }, + { url = "https://files.pythonhosted.org/packages/cf/7c/f3b682fa053cc21373c9a839e6beba7705857075686a05c72e0f8c4980ca/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", size = 148474 }, + { url = "https://files.pythonhosted.org/packages/1e/49/7ab74d4ac537ece3bc3334ee08645e231f39f7d6df6347b29a74b0537103/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", size = 140232 }, + { url = "https://files.pythonhosted.org/packages/2d/dc/9dacba68c9ac0ae781d40e1a0c0058e26302ea0660e574ddf6797a0347f7/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", size = 140859 }, + { url = "https://files.pythonhosted.org/packages/6c/c2/4a583f800c0708dd22096298e49f887b49d9746d0e78bfc1d7e29816614c/charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", size = 92509 }, + { url = "https://files.pythonhosted.org/packages/57/ec/80c8d48ac8b1741d5b963797b7c0c869335619e13d4744ca2f67fc11c6fc/charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", size = 99870 }, + { url = "https://files.pythonhosted.org/packages/d1/b2/fcedc8255ec42afee97f9e6f0145c734bbe104aac28300214593eb326f1d/charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8", size = 192892 }, + { url = "https://files.pythonhosted.org/packages/2e/7d/2259318c202f3d17f3fe6438149b3b9e706d1070fe3fcbb28049730bb25c/charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b", size = 122213 }, + { url = "https://files.pythonhosted.org/packages/3a/52/9f9d17c3b54dc238de384c4cb5a2ef0e27985b42a0e5cc8e8a31d918d48d/charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6", size = 119404 }, + { url = "https://files.pythonhosted.org/packages/99/b0/9c365f6d79a9f0f3c379ddb40a256a67aa69c59609608fe7feb6235896e1/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", size = 137275 }, + { url = "https://files.pythonhosted.org/packages/91/33/749df346e93d7a30cdcb90cbfdd41a06026317bfbfb62cd68307c1a3c543/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", size = 147518 }, + { url = "https://files.pythonhosted.org/packages/72/1a/641d5c9f59e6af4c7b53da463d07600a695b9824e20849cb6eea8a627761/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", size = 140182 }, + { url = "https://files.pythonhosted.org/packages/ee/fb/14d30eb4956408ee3ae09ad34299131fb383c47df355ddb428a7331cfa1e/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", size = 141869 }, + { url = "https://files.pythonhosted.org/packages/df/3e/a06b18788ca2eb6695c9b22325b6fde7dde0f1d1838b1792a0076f58fe9d/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", size = 144042 }, + { url = "https://files.pythonhosted.org/packages/45/59/3d27019d3b447a88fe7e7d004a1e04be220227760264cc41b405e863891b/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", size = 138275 }, + { url = "https://files.pythonhosted.org/packages/7b/ef/5eb105530b4da8ae37d506ccfa25057961b7b63d581def6f99165ea89c7e/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", size = 144819 }, + { url = "https://files.pythonhosted.org/packages/a2/51/e5023f937d7f307c948ed3e5c29c4b7a3e42ed2ee0b8cdf8f3a706089bf0/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", size = 149415 }, + { url = "https://files.pythonhosted.org/packages/24/9d/2e3ef673dfd5be0154b20363c5cdcc5606f35666544381bee15af3778239/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", size = 141212 }, + { url = "https://files.pythonhosted.org/packages/5b/ae/ce2c12fcac59cb3860b2e2d76dc405253a4475436b1861d95fe75bdea520/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", size = 142167 }, + { url = "https://files.pythonhosted.org/packages/ed/3a/a448bf035dce5da359daf9ae8a16b8a39623cc395a2ffb1620aa1bce62b0/charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7", size = 93041 }, + { url = "https://files.pythonhosted.org/packages/b6/7c/8debebb4f90174074b827c63242c23851bdf00a532489fba57fef3416e40/charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001", size = 100397 }, + { url = "https://files.pythonhosted.org/packages/28/76/e6222113b83e3622caa4bb41032d0b1bf785250607392e1b778aca0b8a7d/charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", size = 48543 }, +] + +[[package]] +name = "click" +version = "8.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "coloredlogs" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "humanfriendly" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018 }, +] + +[[package]] +name = "configargparse" +version = "1.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/8a/73f1008adfad01cb923255b924b1528727b8270e67cb4ef41eabdc7d783e/ConfigArgParse-1.7.tar.gz", hash = "sha256:e7067471884de5478c58a511e529f0f9bd1c66bfef1dea90935438d6c23306d1", size = 43817 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/b3/b4ac838711fd74a2b4e6f746703cf9dd2cf5462d17dac07e349234e21b97/ConfigArgParse-1.7-py3-none-any.whl", hash = "sha256:d249da6591465c6c26df64a9f73d2536e743be2f244eb3ebe61114af2f94f86b", size = 25489 }, +] + +[[package]] +name = "contourpy" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/a3/48ddc7ae832b000952cf4be64452381d150a41a2299c2eb19237168528d1/contourpy-1.2.0.tar.gz", hash = "sha256:171f311cb758de7da13fc53af221ae47a5877be5a0843a9fe150818c51ed276a", size = 13455881 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/ea/f6e90933d82cc5aacf52f886a1c01f47f96eba99108ca2929c7b3ef45f82/contourpy-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0274c1cb63625972c0c007ab14dd9ba9e199c36ae1a231ce45d725cbcbfd10a8", size = 256873 }, + { url = "https://files.pythonhosted.org/packages/fe/26/43821d61b7ee62c1809ec852bc572aaf4c27f101ebcebbbcce29a5ee0445/contourpy-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ab459a1cbbf18e8698399c595a01f6dcc5c138220ca3ea9e7e6126232d102bb4", size = 242211 }, + { url = "https://files.pythonhosted.org/packages/9b/99/c8fb63072a7573fe7682e1786a021f29f9c5f660a3aafcdce80b9ee8348d/contourpy-1.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fdd887f17c2f4572ce548461e4f96396681212d858cae7bd52ba3310bc6f00f", size = 293195 }, + { url = "https://files.pythonhosted.org/packages/c7/a7/ae0b4bb8e0c865270d02ee619981413996dc10ddf1fd2689c938173ff62f/contourpy-1.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5d16edfc3fc09968e09ddffada434b3bf989bf4911535e04eada58469873e28e", size = 332279 }, + { url = "https://files.pythonhosted.org/packages/94/7c/682228b9085ff323fb7e946fe139072e5f21b71360cf91f36ea079d4ea95/contourpy-1.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c203f617abc0dde5792beb586f827021069fb6d403d7f4d5c2b543d87edceb9", size = 305326 }, + { url = "https://files.pythonhosted.org/packages/58/56/e2c43dcfa1f9c7db4d5e3d6f5134b24ed953f4e2133a4b12f0062148db58/contourpy-1.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b69303ceb2e4d4f146bf82fda78891ef7bcd80c41bf16bfca3d0d7eb545448aa", size = 310732 }, + { url = "https://files.pythonhosted.org/packages/94/0b/8495c4582057abc8377f945f6e11a86f1c07ad7b32fd4fdc968478cd0324/contourpy-1.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:884c3f9d42d7218304bc74a8a7693d172685c84bd7ab2bab1ee567b769696df9", size = 803420 }, + { url = "https://files.pythonhosted.org/packages/d5/1f/40399c7da649297147d404aedaa675cc60018f48ad284630c0d1406133e3/contourpy-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4a1b1208102be6e851f20066bf0e7a96b7d48a07c9b0cfe6d0d4545c2f6cadab", size = 829204 }, + { url = "https://files.pythonhosted.org/packages/8b/01/4be433b60dce7cbce8315cbcdfc016e7d25430a8b94e272355dff79cc3a8/contourpy-1.2.0-cp310-cp310-win32.whl", hash = "sha256:34b9071c040d6fe45d9826cbbe3727d20d83f1b6110d219b83eb0e2a01d79488", size = 165434 }, + { url = "https://files.pythonhosted.org/packages/fd/7c/168f8343f33d861305e18c56901ef1bb675d3c7f977f435ec72751a71a54/contourpy-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:bd2f1ae63998da104f16a8b788f685e55d65760cd1929518fd94cd682bf03e41", size = 186652 }, + { url = "https://files.pythonhosted.org/packages/9b/54/1dafec3c84df1d29119037330f7289db84a679cb2d5283af4ef24d89f532/contourpy-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dd10c26b4eadae44783c45ad6655220426f971c61d9b239e6f7b16d5cdaaa727", size = 258243 }, + { url = "https://files.pythonhosted.org/packages/5b/ac/26fa1057f62beaa2af4c55c6ac733b114a403b746cfe0ce3dc6e4aec921a/contourpy-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5c6b28956b7b232ae801406e529ad7b350d3f09a4fde958dfdf3c0520cdde0dd", size = 243408 }, + { url = "https://files.pythonhosted.org/packages/b7/33/cd0ecc80123f499d76d2fe2807cb4d5638ef8730735c580c8a8a03e1928e/contourpy-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebeac59e9e1eb4b84940d076d9f9a6cec0064e241818bcb6e32124cc5c3e377a", size = 294142 }, + { url = "https://files.pythonhosted.org/packages/6d/75/1b7bf20bf6394e01df2c4b4b3d44d3dc280c16ddaff72724639100bd4314/contourpy-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:139d8d2e1c1dd52d78682f505e980f592ba53c9f73bd6be102233e358b401063", size = 333129 }, + { url = "https://files.pythonhosted.org/packages/22/5b/fedd961dff1877e5d3b83c5201295cfdcdc2438884c2851aa7ecf6cec045/contourpy-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e9dc350fb4c58adc64df3e0703ab076f60aac06e67d48b3848c23647ae4310e", size = 307461 }, + { url = "https://files.pythonhosted.org/packages/e2/83/29a63bbc72839cc6b24b5a0e3d004d4ed4e8439f26460ad9a34e39251904/contourpy-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18fc2b4ed8e4a8fe849d18dce4bd3c7ea637758c6343a1f2bae1e9bd4c9f4686", size = 313352 }, + { url = "https://files.pythonhosted.org/packages/4b/c7/4bac0fc4f1e802ab47e75076d83d2e1448e0668ba6cc9000cf4e9d5bd94a/contourpy-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:16a7380e943a6d52472096cb7ad5264ecee36ed60888e2a3d3814991a0107286", size = 804127 }, + { url = "https://files.pythonhosted.org/packages/e3/47/b3fd5bdc2f6ec13502d57a5bc390ffe62648605ed1689c93b0015150a784/contourpy-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8d8faf05be5ec8e02a4d86f616fc2a0322ff4a4ce26c0f09d9f7fb5330a35c95", size = 829561 }, + { url = "https://files.pythonhosted.org/packages/5c/04/be16038e754169caea4d02d82f8e5cd97dece593e5ac9e05735da0afd0c5/contourpy-1.2.0-cp311-cp311-win32.whl", hash = "sha256:67b7f17679fa62ec82b7e3e611c43a016b887bd64fb933b3ae8638583006c6d6", size = 166197 }, + { url = "https://files.pythonhosted.org/packages/ca/2a/d197a412ec474391ee878b1218cf2fe9c6e963903755887fc5654c06636a/contourpy-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:99ad97258985328b4f207a5e777c1b44a83bfe7cf1f87b99f9c11d4ee477c4de", size = 187556 }, + { url = "https://files.pythonhosted.org/packages/4f/03/839da46999173226bead08794cbd7b4d37c9e6b02686ca74c93556b43258/contourpy-1.2.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:575bcaf957a25d1194903a10bc9f316c136c19f24e0985a2b9b5608bdf5dbfe0", size = 259253 }, + { url = "https://files.pythonhosted.org/packages/f3/9e/8fb3f53144269d3fecdd8786d3a4686eeff55b9b35a3c0772a3f62f71e36/contourpy-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9e6c93b5b2dbcedad20a2f18ec22cae47da0d705d454308063421a3b290d9ea4", size = 242555 }, + { url = "https://files.pythonhosted.org/packages/a6/85/9815ccb5a18ee8c9a46bd5ef20d02b292cd4a99c62553f38c87015f16d59/contourpy-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:464b423bc2a009088f19bdf1f232299e8b6917963e2b7e1d277da5041f33a779", size = 288108 }, + { url = "https://files.pythonhosted.org/packages/5a/d9/4df5c26bd0f496c8cd7940fd53db95d07deeb98518f02f805ce570590da8/contourpy-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:68ce4788b7d93e47f84edd3f1f95acdcd142ae60bc0e5493bfd120683d2d4316", size = 330810 }, + { url = "https://files.pythonhosted.org/packages/67/d4/8aae9793a0cfde72959312521ebd3aa635c260c3d580448e8db6bdcdd1aa/contourpy-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d7d1f8871998cdff5d2ff6a087e5e1780139abe2838e85b0b46b7ae6cc25399", size = 305290 }, + { url = "https://files.pythonhosted.org/packages/20/84/ffddcdcc579cbf7213fd92a3578ca08a931a3bf879a22deb5a83ffc5002c/contourpy-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e739530c662a8d6d42c37c2ed52a6f0932c2d4a3e8c1f90692ad0ce1274abe0", size = 303937 }, + { url = "https://files.pythonhosted.org/packages/d8/ad/6e570cf525f909da94559ed716189f92f529bc7b5f78645733c44619a0e2/contourpy-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:247b9d16535acaa766d03037d8e8fb20866d054d3c7fbf6fd1f993f11fc60ca0", size = 801977 }, + { url = "https://files.pythonhosted.org/packages/36/b4/55f23482c596eca36d16fc668b147865c56fcf90353f4c57f073d8d5e532/contourpy-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:461e3ae84cd90b30f8d533f07d87c00379644205b1d33a5ea03381edc4b69431", size = 827442 }, + { url = "https://files.pythonhosted.org/packages/e9/47/9c081b1f11d6053cb0aa4c46b7de2ea2849a4a8d40de81c7bc3f99773b02/contourpy-1.2.0-cp312-cp312-win32.whl", hash = "sha256:1c2559d6cffc94890b0529ea7eeecc20d6fadc1539273aa27faf503eb4656d8f", size = 165363 }, + { url = "https://files.pythonhosted.org/packages/8e/ae/a6353db548bff1a592b85ae6bb80275f0a51dc25a0410d059e5b33183e36/contourpy-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:491b1917afdd8638a05b611a56d46587d5a632cabead889a5440f7c638bc6ed9", size = 187731 }, +] + +[[package]] +name = "coverage" +version = "7.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/12/3669b6382792783e92046730ad3327f53b2726f0603f4c311c4da4824222/coverage-7.6.4.tar.gz", hash = "sha256:29fc0f17b1d3fea332f8001d4558f8214af7f1d87a345f3a133c901d60347c73", size = 798716 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/93/4ad92f71e28ece5c0326e5f4a6630aa4928a8846654a65cfff69b49b95b9/coverage-7.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f8ae553cba74085db385d489c7a792ad66f7f9ba2ee85bfa508aeb84cf0ba07", size = 206713 }, + { url = "https://files.pythonhosted.org/packages/01/ae/747a580b1eda3f2e431d87de48f0604bd7bc92e52a1a95185a4aa585bc47/coverage-7.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8165b796df0bd42e10527a3f493c592ba494f16ef3c8b531288e3d0d72c1f6f0", size = 207149 }, + { url = "https://files.pythonhosted.org/packages/07/1a/1f573f8a6145f6d4c9130bbc120e0024daf1b24cf2a78d7393fa6eb6aba7/coverage-7.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7c8b95bf47db6d19096a5e052ffca0a05f335bc63cef281a6e8fe864d450a72", size = 235584 }, + { url = "https://files.pythonhosted.org/packages/40/42/c8523f2e4db34aa9389caee0d3688b6ada7a84fcc782e943a868a7f302bd/coverage-7.6.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ed9281d1b52628e81393f5eaee24a45cbd64965f41857559c2b7ff19385df51", size = 233486 }, + { url = "https://files.pythonhosted.org/packages/8d/95/565c310fffa16ede1a042e9ea1ca3962af0d8eb5543bc72df6b91dc0c3d5/coverage-7.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0809082ee480bb8f7416507538243c8863ac74fd8a5d2485c46f0f7499f2b491", size = 234649 }, + { url = "https://files.pythonhosted.org/packages/d5/81/3b550674d98968ec29c92e3e8650682be6c8b1fa7581a059e7e12e74c431/coverage-7.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d541423cdd416b78626b55f123412fcf979d22a2c39fce251b350de38c15c15b", size = 233744 }, + { url = "https://files.pythonhosted.org/packages/0d/70/d66c7f51b3e33aabc5ea9f9624c1c9d9655472962270eb5e7b0d32707224/coverage-7.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58809e238a8a12a625c70450b48e8767cff9eb67c62e6154a642b21ddf79baea", size = 232204 }, + { url = "https://files.pythonhosted.org/packages/23/2d/2b3a2dbed7a5f40693404c8a09e779d7c1a5fbed089d3e7224c002129ec8/coverage-7.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c9b8e184898ed014884ca84c70562b4a82cbc63b044d366fedc68bc2b2f3394a", size = 233335 }, + { url = "https://files.pythonhosted.org/packages/5a/4f/92d1d2ad720d698a4e71c176eacf531bfb8e0721d5ad560556f2c484a513/coverage-7.6.4-cp310-cp310-win32.whl", hash = "sha256:6bd818b7ea14bc6e1f06e241e8234508b21edf1b242d49831831a9450e2f35fa", size = 209435 }, + { url = "https://files.pythonhosted.org/packages/c7/b9/cdf158e7991e2287bcf9082670928badb73d310047facac203ff8dcd5ff3/coverage-7.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:06babbb8f4e74b063dbaeb74ad68dfce9186c595a15f11f5d5683f748fa1d172", size = 210243 }, + { url = "https://files.pythonhosted.org/packages/87/31/9c0cf84f0dfcbe4215b7eb95c31777cdc0483c13390e69584c8150c85175/coverage-7.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:73d2b73584446e66ee633eaad1a56aad577c077f46c35ca3283cd687b7715b0b", size = 206819 }, + { url = "https://files.pythonhosted.org/packages/53/ed/a38401079ad320ad6e054a01ec2b61d270511aeb3c201c80e99c841229d5/coverage-7.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:51b44306032045b383a7a8a2c13878de375117946d68dcb54308111f39775a25", size = 207263 }, + { url = "https://files.pythonhosted.org/packages/20/e7/c3ad33b179ab4213f0d70da25a9c214d52464efa11caeab438592eb1d837/coverage-7.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b3fb02fe73bed561fa12d279a417b432e5b50fe03e8d663d61b3d5990f29546", size = 239205 }, + { url = "https://files.pythonhosted.org/packages/36/91/fc02e8d8e694f557752120487fd982f654ba1421bbaa5560debf96ddceda/coverage-7.6.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed8fe9189d2beb6edc14d3ad19800626e1d9f2d975e436f84e19efb7fa19469b", size = 236612 }, + { url = "https://files.pythonhosted.org/packages/cc/57/cb08f0eda0389a9a8aaa4fc1f9fec7ac361c3e2d68efd5890d7042c18aa3/coverage-7.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b369ead6527d025a0fe7bd3864e46dbee3aa8f652d48df6174f8d0bac9e26e0e", size = 238479 }, + { url = "https://files.pythonhosted.org/packages/d5/c9/2c7681a9b3ca6e6f43d489c2e6653a53278ed857fd6e7010490c307b0a47/coverage-7.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ade3ca1e5f0ff46b678b66201f7ff477e8fa11fb537f3b55c3f0568fbfe6e718", size = 237405 }, + { url = "https://files.pythonhosted.org/packages/b5/4e/ebfc6944b96317df8b537ae875d2e57c27b84eb98820bc0a1055f358f056/coverage-7.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:27fb4a050aaf18772db513091c9c13f6cb94ed40eacdef8dad8411d92d9992db", size = 236038 }, + { url = "https://files.pythonhosted.org/packages/13/f2/3a0bf1841a97c0654905e2ef531170f02c89fad2555879db8fe41a097871/coverage-7.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f704f0998911abf728a7783799444fcbbe8261c4a6c166f667937ae6a8aa522", size = 236812 }, + { url = "https://files.pythonhosted.org/packages/b9/9c/66bf59226b52ce6ed9541b02d33e80a6e816a832558fbdc1111a7bd3abd4/coverage-7.6.4-cp311-cp311-win32.whl", hash = "sha256:29155cd511ee058e260db648b6182c419422a0d2e9a4fa44501898cf918866cf", size = 209400 }, + { url = "https://files.pythonhosted.org/packages/2a/a0/b0790934c04dfc8d658d4a62acb8f7ca0efdf3818456fcad757b11c6479d/coverage-7.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:8902dd6a30173d4ef09954bfcb24b5d7b5190cf14a43170e386979651e09ba19", size = 210243 }, + { url = "https://files.pythonhosted.org/packages/7d/e7/9291de916d084f41adddfd4b82246e68d61d6a75747f075f7e64628998d2/coverage-7.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12394842a3a8affa3ba62b0d4ab7e9e210c5e366fbac3e8b2a68636fb19892c2", size = 207013 }, + { url = "https://files.pythonhosted.org/packages/27/03/932c2c5717a7fa80cd43c6a07d3177076d97b79f12f40f882f9916db0063/coverage-7.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b6b4c83d8e8ea79f27ab80778c19bc037759aea298da4b56621f4474ffeb117", size = 207251 }, + { url = "https://files.pythonhosted.org/packages/d5/3f/0af47dcb9327f65a45455fbca846fe96eb57c153af46c4754a3ba678938a/coverage-7.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d5b8007f81b88696d06f7df0cb9af0d3b835fe0c8dbf489bad70b45f0e45613", size = 240268 }, + { url = "https://files.pythonhosted.org/packages/8a/3c/37a9d81bbd4b23bc7d46ca820e16174c613579c66342faa390a271d2e18b/coverage-7.6.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b57b768feb866f44eeed9f46975f3d6406380275c5ddfe22f531a2bf187eda27", size = 237298 }, + { url = "https://files.pythonhosted.org/packages/c0/70/6b0627e5bd68204ee580126ed3513140b2298995c1233bd67404b4e44d0e/coverage-7.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5915fcdec0e54ee229926868e9b08586376cae1f5faa9bbaf8faf3561b393d52", size = 239367 }, + { url = "https://files.pythonhosted.org/packages/3c/eb/634d7dfab24ac3b790bebaf9da0f4a5352cbc125ce6a9d5c6cf4c6cae3c7/coverage-7.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b58c672d14f16ed92a48db984612f5ce3836ae7d72cdd161001cc54512571f2", size = 238853 }, + { url = "https://files.pythonhosted.org/packages/d9/0d/8e3ed00f1266ef7472a4e33458f42e39492e01a64281084fb3043553d3f1/coverage-7.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2fdef0d83a2d08d69b1f2210a93c416d54e14d9eb398f6ab2f0a209433db19e1", size = 237160 }, + { url = "https://files.pythonhosted.org/packages/ce/9c/4337f468ef0ab7a2e0887a9c9da0e58e2eada6fc6cbee637a4acd5dfd8a9/coverage-7.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cf717ee42012be8c0cb205dbbf18ffa9003c4cbf4ad078db47b95e10748eec5", size = 238824 }, + { url = "https://files.pythonhosted.org/packages/5e/09/3e94912b8dd37251377bb02727a33a67ee96b84bbbe092f132b401ca5dd9/coverage-7.6.4-cp312-cp312-win32.whl", hash = "sha256:7bb92c539a624cf86296dd0c68cd5cc286c9eef2d0c3b8b192b604ce9de20a17", size = 209639 }, + { url = "https://files.pythonhosted.org/packages/01/69/d4f3a4101171f32bc5b3caec8ff94c2c60f700107a6aaef7244b2c166793/coverage-7.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:1032e178b76a4e2b5b32e19d0fd0abbce4b58e77a1ca695820d10e491fa32b08", size = 210428 }, + { url = "https://files.pythonhosted.org/packages/c2/4d/2dede4f7cb5a70fb0bb40a57627fddf1dbdc6b9c1db81f7c4dcdcb19e2f4/coverage-7.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:023bf8ee3ec6d35af9c1c6ccc1d18fa69afa1cb29eaac57cb064dbb262a517f9", size = 207039 }, + { url = "https://files.pythonhosted.org/packages/3f/f9/d86368ae8c79e28f1fb458ebc76ae9ff3e8bd8069adc24e8f2fed03c58b7/coverage-7.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0ac3d42cb51c4b12df9c5f0dd2f13a4f24f01943627120ec4d293c9181219ba", size = 207298 }, + { url = "https://files.pythonhosted.org/packages/64/c5/b4cc3c3f64622c58fbfd4d8b9a7a8ce9d355f172f91fcabbba1f026852f6/coverage-7.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8fe4984b431f8621ca53d9380901f62bfb54ff759a1348cd140490ada7b693c", size = 239813 }, + { url = "https://files.pythonhosted.org/packages/8a/86/14c42e60b70a79b26099e4d289ccdfefbc68624d096f4481163085aa614c/coverage-7.6.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5fbd612f8a091954a0c8dd4c0b571b973487277d26476f8480bfa4b2a65b5d06", size = 236959 }, + { url = "https://files.pythonhosted.org/packages/7f/f8/4436a643631a2fbab4b44d54f515028f6099bfb1cd95b13cfbf701e7f2f2/coverage-7.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dacbc52de979f2823a819571f2e3a350a7e36b8cb7484cdb1e289bceaf35305f", size = 238950 }, + { url = "https://files.pythonhosted.org/packages/49/50/1571810ddd01f99a0a8be464a4ac8b147f322cd1e8e296a1528984fc560b/coverage-7.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dab4d16dfef34b185032580e2f2f89253d302facba093d5fa9dbe04f569c4f4b", size = 238610 }, + { url = "https://files.pythonhosted.org/packages/f3/8c/6312d241fe7cbd1f0cade34a62fea6f333d1a261255d76b9a87074d8703c/coverage-7.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:862264b12ebb65ad8d863d51f17758b1684560b66ab02770d4f0baf2ff75da21", size = 236697 }, + { url = "https://files.pythonhosted.org/packages/ce/5f/fef33dfd05d87ee9030f614c857deb6df6556b8f6a1c51bbbb41e24ee5ac/coverage-7.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5beb1ee382ad32afe424097de57134175fea3faf847b9af002cc7895be4e2a5a", size = 238541 }, + { url = "https://files.pythonhosted.org/packages/a9/64/6a984b6e92e1ea1353b7ffa08e27f707a5e29b044622445859200f541e8c/coverage-7.6.4-cp313-cp313-win32.whl", hash = "sha256:bf20494da9653f6410213424f5f8ad0ed885e01f7e8e59811f572bdb20b8972e", size = 209707 }, + { url = "https://files.pythonhosted.org/packages/5c/60/ce5a9e942e9543783b3db5d942e0578b391c25cdd5e7f342d854ea83d6b7/coverage-7.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:182e6cd5c040cec0a1c8d415a87b67ed01193ed9ad458ee427741c7d8513d963", size = 210439 }, + { url = "https://files.pythonhosted.org/packages/78/53/6719677e92c308207e7f10561a1b16ab8b5c00e9328efc9af7cfd6fb703e/coverage-7.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a181e99301a0ae128493a24cfe5cfb5b488c4e0bf2f8702091473d033494d04f", size = 207784 }, + { url = "https://files.pythonhosted.org/packages/fa/dd/7054928930671fcb39ae6a83bb71d9ab5f0afb733172543ced4b09a115ca/coverage-7.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:df57bdbeffe694e7842092c5e2e0bc80fff7f43379d465f932ef36f027179806", size = 208058 }, + { url = "https://files.pythonhosted.org/packages/b5/7d/fd656ddc2b38301927b9eb3aae3fe827e7aa82e691923ed43721fd9423c9/coverage-7.6.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bcd1069e710600e8e4cf27f65c90c7843fa8edfb4520fb0ccb88894cad08b11", size = 250772 }, + { url = "https://files.pythonhosted.org/packages/90/d0/eb9a3cc2100b83064bb086f18aedde3afffd7de6ead28f69736c00b7f302/coverage-7.6.4-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99b41d18e6b2a48ba949418db48159d7a2e81c5cc290fc934b7d2380515bd0e3", size = 246490 }, + { url = "https://files.pythonhosted.org/packages/45/44/3f64f38f6faab8a0cfd2c6bc6eb4c6daead246b97cf5f8fc23bf3788f841/coverage-7.6.4-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1e54712ba3474f34b7ef7a41e65bd9037ad47916ccb1cc78769bae324c01a", size = 248848 }, + { url = "https://files.pythonhosted.org/packages/5d/11/4c465a5f98656821e499f4b4619929bd5a34639c466021740ecdca42aa30/coverage-7.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:53d202fd109416ce011578f321460795abfe10bb901b883cafd9b3ef851bacfc", size = 248340 }, + { url = "https://files.pythonhosted.org/packages/f1/96/ebecda2d016cce9da812f404f720ca5df83c6b29f65dc80d2000d0078741/coverage-7.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:c48167910a8f644671de9f2083a23630fbf7a1cb70ce939440cd3328e0919f70", size = 246229 }, + { url = "https://files.pythonhosted.org/packages/16/d9/3d820c00066ae55d69e6d0eae11d6149a5ca7546de469ba9d597f01bf2d7/coverage-7.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cc8ff50b50ce532de2fa7a7daae9dd12f0a699bfcd47f20945364e5c31799fef", size = 247510 }, + { url = "https://files.pythonhosted.org/packages/8f/c3/4fa1eb412bb288ff6bfcc163c11700ff06e02c5fad8513817186e460ed43/coverage-7.6.4-cp313-cp313t-win32.whl", hash = "sha256:b8d3a03d9bfcaf5b0141d07a88456bb6a4c3ce55c080712fec8418ef3610230e", size = 210353 }, + { url = "https://files.pythonhosted.org/packages/7e/77/03fc2979d1538884d921c2013075917fc927f41cd8526909852fe4494112/coverage-7.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:f3ddf056d3ebcf6ce47bdaf56142af51bb7fad09e4af310241e9db7a3a8022e1", size = 211502 }, + { url = "https://files.pythonhosted.org/packages/cc/56/e1d75e8981a2a92c2a777e67c26efa96c66da59d645423146eb9ff3a851b/coverage-7.6.4-pp39.pp310-none-any.whl", hash = "sha256:3c65d37f3a9ebb703e710befdc489a38683a5b152242664b973a7b7b22348a4e", size = 198954 }, +] + +[package.optional-dependencies] +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/f4/d2542e186fe33ec1cc542770fb17466421ed54f4ffe04d00fe9549d0a467/Cython-3.0.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a846e0a38e2b24e9a5c5dc74b0e54c6e29420d88d1dafabc99e0fc0f3e338636", size = 3100459 }, + { url = "https://files.pythonhosted.org/packages/fc/27/2652f395aa708fb3081148e0df3ab700bd7288636c65332ef7febad6a380/Cython-3.0.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45523fdc2b78d79b32834cc1cc12dc2ca8967af87e22a3ee1bff20e77c7f5520", size = 3456626 }, + { url = "https://files.pythonhosted.org/packages/f9/bd/e8a1d26d04c08a67bcc383f2ea5493a4e77f37a8770ead00a238b08ad729/Cython-3.0.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa0b7f3f841fe087410cab66778e2d3fb20ae2d2078a2be3dffe66c6574be39", size = 3621379 }, + { url = "https://files.pythonhosted.org/packages/03/ae/ead7ec03d0062d439879d41b7830e4f2480213f7beabf2f7052a191cc6f7/Cython-3.0.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e87294e33e40c289c77a135f491cd721bd089f193f956f7b8ed5aa2d0b8c558f", size = 3671873 }, + { url = "https://files.pythonhosted.org/packages/63/b0/81dad725604d7b529c492f873a7fa1b5800704a9f26e100ed25e9fd8d057/Cython-3.0.8-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a1df7a129344b1215c20096d33c00193437df1a8fcca25b71f17c23b1a44f782", size = 3463832 }, + { url = "https://files.pythonhosted.org/packages/13/cd/72b8e0af597ac1b376421847acf6d6fa252e60059a2a00dcf05ceb16d28f/Cython-3.0.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:13c2a5e57a0358da467d97667297bf820b62a1a87ae47c5f87938b9bb593acbd", size = 3618325 }, + { url = "https://files.pythonhosted.org/packages/ef/73/11a4355d8b8966504c751e5bcb25916c4140de27bb2ba1b54ff21994d7fe/Cython-3.0.8-cp310-cp310-win32.whl", hash = "sha256:96b028f044f5880e3cb18ecdcfc6c8d3ce9d0af28418d5ab464509f26d8adf12", size = 2571305 }, + { url = "https://files.pythonhosted.org/packages/18/15/fdc0c3552d20f9337b134a36d786da24e47998fc39f62cb61c1534f26123/Cython-3.0.8-cp310-cp310-win_amd64.whl", hash = "sha256:8140597a8b5cc4f119a1190f5a2228a84f5ca6d8d9ec386cfce24663f48b2539", size = 2776113 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/70/e2/3e3e448b7a94887bec3235bcb71957b6681dc42b4536459f8f54d46fa936/Cython-3.0.8-cp311-cp311-win32.whl", hash = "sha256:c8aa05f5e17f8042a3be052c24f2edc013fb8af874b0bf76907d16c51b4e7871", size = 2572819 }, + { url = "https://files.pythonhosted.org/packages/85/7d/58635941dfbb5b4e197adb88080b9cbfb230dc3b75683698a530a1989bdb/Cython-3.0.8-cp311-cp311-win_amd64.whl", hash = "sha256:000dc9e135d0eec6ecb2b40a5b02d0868a2f8d2e027a41b0fe16a908a9e6de02", size = 2784167 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/2b/cb/132115d07a0b9d4f075e0741db70a5416b424dcd875b2bb0dd805e818222/Cython-3.0.8-cp312-cp312-win32.whl", hash = "sha256:85077915a93e359a9b920280d214dc0cf8a62773e1f3d7d30fab8ea4daed670c", size = 2602579 }, + { url = "https://files.pythonhosted.org/packages/b4/69/cb4620287cd9ef461103e122c0a2ae7f7ecf183e02510676fb5a15c95b05/Cython-3.0.8-cp312-cp312-win_amd64.whl", hash = "sha256:0cb2dcc565c7851f75d496f724a384a790fab12d1b82461b663e66605bec429a", size = 2791268 }, + { url = "https://files.pythonhosted.org/packages/e3/7f/f584f5d15323feb897d42ef0e9d910649e2150d7a30cf7e7a8cc1d236e6f/Cython-3.0.8-py2.py3-none-any.whl", hash = "sha256:171b27051253d3f9108e9759e504ba59ff06e7f7ba944457f94deaf9c21bf0b6", size = 1168213 }, +] + +[[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 } + +[[package]] +name = "exceptiongroup" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/1c/beef724eaf5b01bb44b6338c8c3494eff7cab376fab4904cfbbc3585dc79/exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68", size = 26264 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/9a/5028fd52db10e600f1c4674441b968cf2ea4959085bfb5b99fb1250e5f68/exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14", size = 16210 }, +] + +[[package]] +name = "fastapi" +version = "0.115.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/28/c5d26e5860df807241909a961a37d45e10533acef95fc368066c7dd186cd/fastapi-0.115.11.tar.gz", hash = "sha256:cc81f03f688678b92600a65a5e618b93592c65005db37157147204d8924bf94f", size = 294441 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/5d/4d8bbb94f0dbc22732350c06965e40740f4a92ca560e90bb566f4f73af41/fastapi-0.115.11-py3-none-any.whl", hash = "sha256:32e1541b7b74602e4ef4a0260ecaf3aadf9d4f19590bba3e1bf2ac4666aa2c64", size = 94926 }, +] + +[[package]] +name = "filelock" +version = "3.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/70/41905c80dcfe71b22fb06827b8eae65781783d4a14194bce79d16a013263/filelock-3.13.1.tar.gz", hash = "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e", size = 14553 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/54/84d42a0bee35edba99dee7b59a8d4970eccdd44b99fe728ed912106fc781/filelock-3.13.1-py3-none-any.whl", hash = "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c", size = 11740 }, +] + +[[package]] +name = "flask" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/09/c1a7354d3925a3c6c8cfdebf4245bae67d633ffda1ba415add06ffc839c5/flask-3.0.0.tar.gz", hash = "sha256:cfadcdb638b609361d29ec22360d6070a77d7463dcb3ab08d2c2f2f168845f58", size = 674171 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/42/015c23096649b908c809c69388a805a571a3bea44362fe87e33fc3afa01f/flask-3.0.0-py3-none-any.whl", hash = "sha256:21128f47e4e3b9d597a3e8521a329bf56909b690fcc3fa3e477725aa81367638", size = 99724 }, +] + +[[package]] +name = "flask-cors" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/6a/a8d56d60bcfa1ec3e4fdad81b45aafd508c3bd5c244a16526fa29139d7d4/flask_cors-4.0.1.tar.gz", hash = "sha256:eeb69b342142fdbf4766ad99357a7f3876a2ceb77689dc10ff912aac06c389e4", size = 30306 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/52/2aa6285f104616f73ee1ad7905a16b2b35af0143034ad0cf7b64bcba715c/Flask_Cors-4.0.1-py2.py3-none-any.whl", hash = "sha256:f2a704e4458665580c074b714c4627dd5a306b333deb9074d0b1794dfa2fb677", size = 14290 }, +] + +[[package]] +name = "flask-login" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/6e/2f4e13e373bb49e68c02c51ceadd22d172715a06716f9299d9df01b6ddb2/Flask-Login-0.6.3.tar.gz", hash = "sha256:5e23d14a607ef12806c699590b89d0f0e0d67baeec599d75947bf9c147330333", size = 48834 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/f5/67e9cc5c2036f58115f9fe0f00d203cf6780c3ff8ae0e705e7a9d9e8ff9e/Flask_Login-0.6.3-py3-none-any.whl", hash = "sha256:849b25b82a436bf830a054e74214074af59097171562ab10bfa999e6b78aae5d", size = 17303 }, +] + +[[package]] +name = "flatbuffers" +version = "23.5.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/6e/3e52cd294d8e7a61e010973cce076a0cb2c6c0dfd4d0b7a13648c1b98329/flatbuffers-23.5.26.tar.gz", hash = "sha256:9ea1144cac05ce5d86e2859f431c6cd5e66cd9c78c558317c7955fb8d4c78d89", size = 22114 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/12/d5c79ee252793ffe845d58a913197bfa02ae9a0b5c9bc3dc4b58d477b9e7/flatbuffers-23.5.26-py2.py3-none-any.whl", hash = "sha256:c0ff356da363087b915fde4b8b45bdda73432fc17cddb3c8157472eab1422ad1", size = 26744 }, +] + +[[package]] +name = "fonttools" +version = "4.47.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/cd/75d24afa673edf92fd04657fad7d3b5e20c4abc3cad5bc14e5e30051c1f0/fonttools-4.47.2.tar.gz", hash = "sha256:7df26dd3650e98ca45f1e29883c96a0b9f5bb6af8d632a6a108bc744fa0bd9b3", size = 3410067 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/30/02de0b7f3d72f2c4fce3e512b166c1bdbe5a687408474b61eb0114be921c/fonttools-4.47.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3b629108351d25512d4ea1a8393a2dba325b7b7d7308116b605ea3f8e1be88df", size = 2779949 }, + { url = "https://files.pythonhosted.org/packages/9a/52/1a5e1373afb78a040ea0c371ab8a79da121060a8e518968bb8f41457ca90/fonttools-4.47.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c19044256c44fe299d9a73456aabee4b4d06c6b930287be93b533b4737d70aa1", size = 2281336 }, + { url = "https://files.pythonhosted.org/packages/c5/ce/9d3b5bf51aafee024566ebb374f5b040381d92660cb04647af3c5860c611/fonttools-4.47.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8be28c036b9f186e8c7eaf8a11b42373e7e4949f9e9f370202b9da4c4c3f56c", size = 4541692 }, + { url = "https://files.pythonhosted.org/packages/e8/68/af41b7cfd35c7418e17b6a43bb106be4b0f0e5feb405a88dee29b186f2a7/fonttools-4.47.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f83a4daef6d2a202acb9bf572958f91cfde5b10c8ee7fb1d09a4c81e5d851fd8", size = 4600529 }, + { url = "https://files.pythonhosted.org/packages/ab/7e/428dbb4cfc342b7a05cbc9d349e134e7fad6588f4ce2a7128e8e3e58ad3b/fonttools-4.47.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4a5a5318ba5365d992666ac4fe35365f93004109d18858a3e18ae46f67907670", size = 4524215 }, + { url = "https://files.pythonhosted.org/packages/a6/61/762fad1cc1debc4626f2eb373fa999591c63c231fce53d5073574a639531/fonttools-4.47.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8f57ecd742545362a0f7186774b2d1c53423ed9ece67689c93a1055b236f638c", size = 4584778 }, + { url = "https://files.pythonhosted.org/packages/04/30/170ca22284c1d825470e8b5871d6b25d3a70e2f5b185ffb1647d5e11ee4d/fonttools-4.47.2-cp310-cp310-win32.whl", hash = "sha256:a1c154bb85dc9a4cf145250c88d112d88eb414bad81d4cb524d06258dea1bdc0", size = 2131876 }, + { url = "https://files.pythonhosted.org/packages/df/07/4a30437bed355b838b8ce31d14c5983334c31adc97e70c6ecff90c60d6d2/fonttools-4.47.2-cp310-cp310-win_amd64.whl", hash = "sha256:3e2b95dce2ead58fb12524d0ca7d63a63459dd489e7e5838c3cd53557f8933e1", size = 2177937 }, + { url = "https://files.pythonhosted.org/packages/dd/1d/670372323642eada0f7743cfcdd156de6a28d37769c916421fec2f32c814/fonttools-4.47.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:29495d6d109cdbabe73cfb6f419ce67080c3ef9ea1e08d5750240fd4b0c4763b", size = 2782908 }, + { url = "https://files.pythonhosted.org/packages/c1/36/5f0bb863a6575db4c4b67fa9be7f98e4c551dd87638ef327bc180b988998/fonttools-4.47.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0a1d313a415eaaba2b35d6cd33536560deeebd2ed758b9bfb89ab5d97dc5deac", size = 2283501 }, + { url = "https://files.pythonhosted.org/packages/bd/1e/95de682a86567426bcc40a56c9b118ffa97de6cbfcc293addf20994e329d/fonttools-4.47.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90f898cdd67f52f18049250a6474185ef6544c91f27a7bee70d87d77a8daf89c", size = 4848039 }, + { url = "https://files.pythonhosted.org/packages/ef/95/92a0b5fc844c1db734752f8a51431de519cd6b02e7e561efa9e9fd415544/fonttools-4.47.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3480eeb52770ff75140fe7d9a2ec33fb67b07efea0ab5129c7e0c6a639c40c70", size = 4893166 }, + { url = "https://files.pythonhosted.org/packages/ff/e6/ed9dd7ee1afd6cd70eb7237688118fe489dbde962e3765c91c86c095f84b/fonttools-4.47.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0255dbc128fee75fb9be364806b940ed450dd6838672a150d501ee86523ac61e", size = 4815529 }, + { url = "https://files.pythonhosted.org/packages/6b/67/cdffa0b3cd8f863b45125c335bbd3d9dc16ec42f5a8d5b64dd1244c5ce6b/fonttools-4.47.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f791446ff297fd5f1e2247c188de53c1bfb9dd7f0549eba55b73a3c2087a2703", size = 4875414 }, + { url = "https://files.pythonhosted.org/packages/b8/fb/41638e748c8f20f5483987afcf9be746d3ccb9e9600ca62128a27c791a82/fonttools-4.47.2-cp311-cp311-win32.whl", hash = "sha256:740947906590a878a4bde7dd748e85fefa4d470a268b964748403b3ab2aeed6c", size = 2130073 }, + { url = "https://files.pythonhosted.org/packages/a0/ef/93321cf55180a778b4d97919b28739874c0afab90e7b9f5b232db70f47c2/fonttools-4.47.2-cp311-cp311-win_amd64.whl", hash = "sha256:63fbed184979f09a65aa9c88b395ca539c94287ba3a364517698462e13e457c9", size = 2178744 }, + { url = "https://files.pythonhosted.org/packages/c0/bd/4dd1e8a9e632f325d9203ce543402f912f26efd213c8d9efec0180fbac64/fonttools-4.47.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:4ec558c543609e71b2275c4894e93493f65d2f41c15fe1d089080c1d0bb4d635", size = 2754076 }, + { url = "https://files.pythonhosted.org/packages/e6/4d/c2ebaac81dadbc3fc3c3c2fa5fe7b16429dc713b1b8ace49e11e92904d78/fonttools-4.47.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e040f905d542362e07e72e03612a6270c33d38281fd573160e1003e43718d68d", size = 2263784 }, + { url = "https://files.pythonhosted.org/packages/d3/f6/9d484cd275845c7e503a8669a5952a7fa089c7a881babb4dce5ebe6fc5d1/fonttools-4.47.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6dd58cc03016b281bd2c74c84cdaa6bd3ce54c5a7f47478b7657b930ac3ed8eb", size = 4769142 }, + { url = "https://files.pythonhosted.org/packages/7a/bf/c6ae0768a531b38245aac0bb8d30bc05d53d499e09fccdc5d72e7c8d28b6/fonttools-4.47.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32ab2e9702dff0dd4510c7bb958f265a8d3dd5c0e2547e7b5f7a3df4979abb07", size = 4853241 }, + { url = "https://files.pythonhosted.org/packages/2b/f0/c06709666cb7722447efb70ea456c302bd6eb3b997d30076401fb32bca4b/fonttools-4.47.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a808f3c1d1df1f5bf39be869b6e0c263570cdafb5bdb2df66087733f566ea71", size = 4730447 }, + { url = "https://files.pythonhosted.org/packages/3e/71/4c758ae5f4f8047904fc1c6bbbb828248c94cc7aa6406af3a62ede766f25/fonttools-4.47.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ac71e2e201df041a2891067dc36256755b1229ae167edbdc419b16da78732c2f", size = 4809265 }, + { url = "https://files.pythonhosted.org/packages/81/f6/a6912c11280607d48947341e2167502605a3917925c835afcd7dfcabc289/fonttools-4.47.2-cp312-cp312-win32.whl", hash = "sha256:69731e8bea0578b3c28fdb43dbf95b9386e2d49a399e9a4ad736b8e479b08085", size = 2118363 }, + { url = "https://files.pythonhosted.org/packages/81/4b/42d0488765ea5aa308b4e8197cb75366b2124240a73e86f98b6107ccf282/fonttools-4.47.2-cp312-cp312-win_amd64.whl", hash = "sha256:b3e1304e5f19ca861d86a72218ecce68f391646d85c851742d265787f55457a4", size = 2165866 }, + { url = "https://files.pythonhosted.org/packages/af/2f/c34b0f99d46766cf49566d1ee2ee3606e4c9880b5a7d734257dc61c804e9/fonttools-4.47.2-py3-none-any.whl", hash = "sha256:7eb7ad665258fba68fd22228a09f347469d95a97fb88198e133595947a20a184", size = 1063011 }, +] + +[[package]] +name = "fsspec" +version = "2023.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/08/cac914ff6ff46c4500fc4323a939dbe7a0f528cca04e7fd3e859611dea41/fsspec-2023.12.2.tar.gz", hash = "sha256:8548d39e8810b59c38014934f6b31e57f40c1b20f911f4cc2b85389c7e9bf0cb", size = 167507 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/25/fab23259a52ece5670dcb8452e1af34b89e6135ecc17cd4b54b4b479eac6/fsspec-2023.12.2-py3-none-any.whl", hash = "sha256:d800d87f72189a745fa3d6b033b9dc4a34ad069f60ca60b943a63599f5501960", size = 168979 }, +] + +[[package]] +name = "ftfy" +version = "6.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/d3/8650919bc3c7c6e90ee3fa7fd618bf373cbbe55dff043bd67353dbb20cd8/ftfy-6.3.1.tar.gz", hash = "sha256:9b3c3d90f84fb267fe64d375a07b7f8912d817cf86009ae134aa03e1819506ec", size = 308927 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/6e/81d47999aebc1b155f81eca4477a616a70f238a2549848c38983f3c22a82/ftfy-6.3.1-py3-none-any.whl", hash = "sha256:7c70eb532015cd2f9adb53f101fb6c7945988d023a085d127d1573dc49dd0083", size = 44821 }, +] + +[[package]] +name = "gevent" +version = "24.10.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, + { name = "greenlet", marker = "platform_python_implementation == 'CPython'" }, + { name = "zope-event" }, + { name = "zope-interface" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/f0/be10ed5d7721ed2317d7feb59e167603217156c2a6d57f128523e24e673d/gevent-24.10.3.tar.gz", hash = "sha256:aa7ee1bd5cabb2b7ef35105f863b386c8d5e332f754b60cfc354148bd70d35d1", size = 6108837 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/6f/a2100e7883c7bdfc2b45cb60b310ca748762a21596258b9dd01c5c093dbc/gevent-24.10.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:d7a1ad0f2da582f5bd238bca067e1c6c482c30c15a6e4d14aaa3215cbb2232f3", size = 3014382 }, + { url = "https://files.pythonhosted.org/packages/7a/b1/460e4884ed6185d9eb9c4c2e9639d2b254197e46513301c0f63dec22dc90/gevent-24.10.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4e526fdc279c655c1e809b0c34b45844182c2a6b219802da5e411bd2cf5a8ad", size = 4853460 }, + { url = "https://files.pythonhosted.org/packages/ca/f6/7ded98760d381229183ecce8db2edcce96f13e23807d31a90c66dae85304/gevent-24.10.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57a5c4e0bdac482c5f02f240d0354e61362df73501ef6ebafce8ef635cad7527", size = 4977636 }, + { url = "https://files.pythonhosted.org/packages/7d/21/7b928e6029eedb93ef94fc0aee701f497af2e601f0ec00aac0e72e3f450e/gevent-24.10.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d67daed8383326dc8b5e58d88e148d29b6b52274a489e383530b0969ae7b9cb9", size = 5058031 }, + { url = "https://files.pythonhosted.org/packages/00/98/12c03fd004fbeeca01276ffc589f5a368fd741d02582ab7006d1bdef57e7/gevent-24.10.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e24ffea72e27987979c009536fd0868e52239b44afe6cf7135ce8aafd0f108e", size = 6683694 }, + { url = "https://files.pythonhosted.org/packages/64/4c/ea14d971452d3da09e49267e052d8312f112c7835120aed78d22ef14efee/gevent-24.10.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:c1d80090485da1ea3d99205fe97908b31188c1f4857f08b333ffaf2de2e89d18", size = 5286063 }, + { url = "https://files.pythonhosted.org/packages/39/3f/397efff27e637d7306caa00d1560512c44028c25c70be1e72c46b79b1b66/gevent-24.10.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f0c129f81d60cda614acb4b0c5731997ca05b031fb406fcb58ad53a7ade53b13", size = 6817462 }, + { url = "https://files.pythonhosted.org/packages/aa/5d/19939eaa7c5b7c0f37e0a0665a911ddfe1e35c25c512446fc356a065c16e/gevent-24.10.3-cp310-cp310-win_amd64.whl", hash = "sha256:26ca7a6b42d35129617025ac801135118333cad75856ffc3217b38e707383eba", size = 1566631 }, + { url = "https://files.pythonhosted.org/packages/6e/01/1be5cf013826d8baae235976d6a94f3628014fd2db7c071aeec13f82b4d1/gevent-24.10.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:68c3a0d8402755eba7f69022e42e8021192a721ca8341908acc222ea597029b6", size = 2966909 }, + { url = "https://files.pythonhosted.org/packages/fe/3e/7fa9ab023f24d8689e2c77951981f8ea1f25089e0349a0bf8b35ee9b9277/gevent-24.10.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d850a453d66336272be4f1d3a8126777f3efdaea62d053b4829857f91e09755", size = 4913247 }, + { url = "https://files.pythonhosted.org/packages/db/63/6e40eaaa3c2abd1561faff11dc3e6781f8c25e975354b8835762834415af/gevent-24.10.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8e58ee3723f1fbe07d66892f1caa7481c306f653a6829b6fd16cb23d618a5915", size = 5049036 }, + { url = "https://files.pythonhosted.org/packages/94/89/158bc32cdc898dda0481040ac18650022e73133d93460c5af56ca622fe9a/gevent-24.10.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b52382124eca13135a3abe4f65c6bd428656975980a48e51b17aeab68bdb14db", size = 5107299 }, + { url = "https://files.pythonhosted.org/packages/64/91/1abe62ee350fdfac186d33f615d0d3a0b3b140e7ccf23c73547aa0deec44/gevent-24.10.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ca2266e08f43c0e22c028801dff7d92a0b102ef20e4caeb6a46abfb95f6a328", size = 6819625 }, + { url = "https://files.pythonhosted.org/packages/92/8b/0b2fe0d36b7c4d463e46cc68eaf6c14488bd7d86cc37e995c64a0ff7d02f/gevent-24.10.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d758f0d4dbf32502ec87bb9b536ca8055090a16f8305f0ada3ce6f34e70f2fd7", size = 5474079 }, + { url = "https://files.pythonhosted.org/packages/12/7b/9f5abbf0021a50321314f850697e0f46d2e5081168223af2d8544af9d19f/gevent-24.10.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0de6eb3d55c03138fda567d9bfed28487ce5d0928c5107549767a93efdf2be26", size = 6901323 }, + { url = "https://files.pythonhosted.org/packages/8a/63/607715c621ae78ed581b7ba36d076df63feeb352993d521327f865056771/gevent-24.10.3-cp311-cp311-win_amd64.whl", hash = "sha256:385710355eadecdb70428a5ae3e7e5a45dcf888baa1426884588be9d25ac4290", size = 1549468 }, + { url = "https://files.pythonhosted.org/packages/d9/e4/4edbe17001bb3e6fade4ad2d85ca8f9e4eabcbde4aa29aa6889281616e3e/gevent-24.10.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3ad8fb70aa0ebc935729c9699ac31b210a49b689a7b27b7ac9f91676475f3f53", size = 2970952 }, + { url = "https://files.pythonhosted.org/packages/3c/a6/ce0824fe9398ba6b00028a74840f12be1165d5feaacdc028ea953db3d6c3/gevent-24.10.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f18689f7a70d2ed0e75bad5036ec3c89690a493d4cfac8d7cdb258ac04b132bd", size = 5172230 }, + { url = "https://files.pythonhosted.org/packages/25/d4/9002cfb585bfa52c860ed4b1349d1a6400bdf2df9f1bd21df5ff33eea33c/gevent-24.10.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f4f171d4d2018170454d84c934842e1b5f6ce7468ba298f6e7f7cff15000a3", size = 5338394 }, + { url = "https://files.pythonhosted.org/packages/0c/98/222f1a14f22ad2d1cbcc37edb74095264c1f9c7ab49e6423693383462b8a/gevent-24.10.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7021e26d70189b33c27173d4173f27bf4685d6b6f1c0ea50e5335f8491cb110c", size = 5437989 }, + { url = "https://files.pythonhosted.org/packages/bf/e8/cbb46afea3c7ecdc7289e15cb4a6f89903f4f9754a27ca320d3e465abc78/gevent-24.10.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34aea15f9c79f27a8faeaa361bc1e72c773a9b54a1996a2ec4eefc8bcd59a824", size = 6838539 }, + { url = "https://files.pythonhosted.org/packages/69/c3/e43e348f23da404a6d4368a14453ed097cdfca97d5212eaceb987d04a0e1/gevent-24.10.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:8af65a4d4feaec6042c666d22c322a310fba3b47e841ad52f724b9c3ce5da48e", size = 5513842 }, + { url = "https://files.pythonhosted.org/packages/c2/76/84b7c19c072a80900118717a85236859127d630cdf8b079fe42f19649f12/gevent-24.10.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:89c4115e3f5ada55f92b61701a46043fe42f702b5af863b029e4c1a76f6cc2d4", size = 6927374 }, + { url = "https://files.pythonhosted.org/packages/5e/69/0ab1b04c363547058fb5035275c144957b80b36cb6aee715fe6181b0cee9/gevent-24.10.3-cp312-cp312-win_amd64.whl", hash = "sha256:1ce6dab94c0b0d24425ba55712de2f8c9cb21267150ca63f5bb3a0e1f165da99", size = 1546701 }, + { url = "https://files.pythonhosted.org/packages/f7/2d/c783583d7999cd2f2e7aa2d6a1c333d663003ca61255a89ff6a891be95f4/gevent-24.10.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f147e38423fbe96e8731f60a63475b3d2cab2f3d10578d8ee9d10c507c58a2ff", size = 2962857 }, + { url = "https://files.pythonhosted.org/packages/f3/77/d3ce96fd49406f61976e9a3b6c742b97bb274d3b30c68ff190c5b5f81afd/gevent-24.10.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18e6984ec96fc95fd67488555c38ece3015be1f38b1bcceb27b7d6c36b343008", size = 5141676 }, + { url = "https://files.pythonhosted.org/packages/49/f4/f99f893770c316b9d2f03bd684947126cbed0321b89fe5423838974c2025/gevent-24.10.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:051b22e2758accfddb0457728bfc9abf8c3f2ce6bca43f1ff6e07b5ed9e49bf4", size = 5310248 }, + { url = "https://files.pythonhosted.org/packages/e3/0c/67257ba906f76ed82e8f0bd8c00c2a0687b360a1050b70db7e58dff749ab/gevent-24.10.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb5edb6433764119a664bbb148d2aea9990950aa89cc3498f475c2408d523ea3", size = 5407304 }, + { url = "https://files.pythonhosted.org/packages/35/6c/3a72da7c224b0111728130c0f1abc3ee07feff91b37e0ea83db98f4a3eaf/gevent-24.10.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce417bcaaab496bc9c77f75566531e9d93816262037b8b2dbb88b0fdcd66587c", size = 6818624 }, + { url = "https://files.pythonhosted.org/packages/a3/96/cc5f6ecba032a45fc312fe0db2908a893057fd81361eea93845d6c325556/gevent-24.10.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:1c3a828b033fb02b7c31da4d75014a1f82e6c072fc0523456569a57f8b025861", size = 5484356 }, + { url = "https://files.pythonhosted.org/packages/7c/97/e680b2b2f0c291ae4db9813ffbf02c22c2a0f14c8f1a613971385e29ef67/gevent-24.10.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f2ae3efbbd120cdf4a68b7abc27a37e61e6f443c5a06ec2c6ad94c37cd8471ec", size = 6903191 }, + { url = "https://files.pythonhosted.org/packages/1b/1c/b4181957da062d1c060974ec6cb798cc24aeeb28e8cd2ece84eb4b4991f7/gevent-24.10.3-cp313-cp313-win_amd64.whl", hash = "sha256:9e1210334a9bc9f76c3d008e0785ca62214f8a54e1325f6c2ecab3b6a572a015", size = 1545117 }, + { url = "https://files.pythonhosted.org/packages/89/2b/bf4af9950b8f9abd5b4025858f6311930de550e3498bbfeb47c914701a1d/gevent-24.10.3-pp310-pypy310_pp73-macosx_11_0_universal2.whl", hash = "sha256:e534e6a968d74463b11de6c9c67f4b4bf61775fb00f2e6e0f7fcdd412ceade18", size = 1271541 }, +] + +[[package]] +name = "geventhttpclient" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "brotli" }, + { name = "certifi" }, + { name = "gevent" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/14/d4eddae757de44985718a9e38d9e6f2a923d764ed97d0f1cbc1a8aa2b0ef/geventhttpclient-2.3.1.tar.gz", hash = "sha256:b40ddac8517c456818942c7812f555f84702105c82783238c9fcb8dc12675185", size = 69345 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/a5/5e49d6a581b3f1399425e22961c6e341e90c12fa2193ed0adee9afbd864c/geventhttpclient-2.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:da22ab7bf5af4ba3d07cffee6de448b42696e53e7ac1fe97ed289037733bf1c2", size = 71729 }, + { url = "https://files.pythonhosted.org/packages/eb/23/4ff584e5f344dae64b5bc588b65c4ea81083f9d662b9f64cf5f28e5ae9cc/geventhttpclient-2.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2399e3d4e2fae8bbd91756189da6e9d84adf8f3eaace5eef0667874a705a29f8", size = 52062 }, + { url = "https://files.pythonhosted.org/packages/bb/60/6bd8badb97b31a49f4c2b79466abce208a97dad95d447893c7546063fc8a/geventhttpclient-2.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d3e33e87d0d5b9f5782c4e6d3cb7e3592fea41af52713137d04776df7646d71b", size = 51645 }, + { url = "https://files.pythonhosted.org/packages/e1/62/47d431bf05f74aa683d63163a11432bda8f576c86dec8c3bc9d6a156ee03/geventhttpclient-2.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c071db313866c3d0510feb6c0f40ec086ccf7e4a845701b6316c82c06e8b9b29", size = 117838 }, + { url = "https://files.pythonhosted.org/packages/6c/8b/e7c9ae813bb41883a96ad9afcf86465219c3bb682daa8b09448481edef8a/geventhttpclient-2.3.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f36f0c6ef88a27e60af8369d9c2189fe372c6f2943182a7568e0f2ad33bb69f1", size = 123272 }, + { url = "https://files.pythonhosted.org/packages/4d/26/71e9b2526009faadda9f588dac04f8bf837a5b97628ab44145efc3fa796e/geventhttpclient-2.3.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4624843c03a5337282a42247d987c2531193e57255ee307b36eeb4f243a0c21", size = 114319 }, + { url = "https://files.pythonhosted.org/packages/34/8c/1da2960293c42b7a6b01dbe3204b569e4cdb55b8289cb1c7154826500f19/geventhttpclient-2.3.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d614573621ba827c417786057e1e20e9f96c4f6b3878c55b1b7b54e1026693bc", size = 112705 }, + { url = "https://files.pythonhosted.org/packages/a7/a1/4d08ecf0f213fdc63f78a217f87c07c1cb9891e68cdf74c8cbca76298bdb/geventhttpclient-2.3.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5d51330a40ac9762879d0e296c279c1beae8cfa6484bb196ac829242c416b709", size = 121236 }, + { url = "https://files.pythonhosted.org/packages/4f/f7/42ece3e1f54602c518d74364a214da3b35b6be267b335564b7e9f0d37705/geventhttpclient-2.3.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bc9f2162d4e8cb86bb5322d99bfd552088a3eacd540a841298f06bb8bc1f1f03", size = 117859 }, + { url = "https://files.pythonhosted.org/packages/1f/8e/de026b3697bffe5fa1a4938a3882107e378eea826905acf8e46c69b71ffd/geventhttpclient-2.3.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:06e59d3397e63c65ecc7a7561a5289f0cf2e2c2252e29632741e792f57f5d124", size = 127268 }, + { url = "https://files.pythonhosted.org/packages/54/bf/1ee99a322467e6825a24612d306a46ca94b51088170d1b5de0df1c82ab2a/geventhttpclient-2.3.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4436eef515b3e0c1d4a453ae32e047290e780a623c1eddb11026ae9d5fb03d42", size = 116426 }, + { url = "https://files.pythonhosted.org/packages/72/54/10c8ec745b3dcbfd52af62977fec85829749c0325e1a5429d050a4b45e75/geventhttpclient-2.3.1-cp310-cp310-win32.whl", hash = "sha256:5d1cf7d8a4f8e15cc8fd7d88ac4cdb058d6274203a42587e594cc9f0850ac862", size = 47599 }, + { url = "https://files.pythonhosted.org/packages/da/0d/36a47cdeaa83c3b4efdbd18d77720fa27dc40600998f4dedd7c4a1259862/geventhttpclient-2.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:4deaebc121036f7ea95430c2d0f80ab085b15280e6ab677a6360b70e57020e7f", size = 48302 }, + { url = "https://files.pythonhosted.org/packages/56/ad/1fcbbea0465f04d4425960e3737d4d8ae6407043cfc88688fb17b9064160/geventhttpclient-2.3.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f0ae055b9ce1704f2ce72c0847df28f4e14dbb3eea79256cda6c909d82688ea3", size = 71733 }, + { url = "https://files.pythonhosted.org/packages/06/1a/10e547adb675beea407ff7117ecb4e5063534569ac14bb4360279d2888dd/geventhttpclient-2.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f087af2ac439495b5388841d6f3c4de8d2573ca9870593d78f7b554aa5cfa7f5", size = 52060 }, + { url = "https://files.pythonhosted.org/packages/e0/c0/9960ac6e8818a00702743cd2a9637d6f26909ac7ac59ca231f446e367b20/geventhttpclient-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:76c367d175810facfe56281e516c9a5a4a191eff76641faaa30aa33882ed4b2f", size = 51649 }, + { url = "https://files.pythonhosted.org/packages/58/3a/b032cd8f885dafdfa002a8a0e4e21b633713798ec08e19010b815fbfead6/geventhttpclient-2.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a58376d0d461fe0322ff2ad362553b437daee1eeb92b4c0e3b1ffef9e77defbe", size = 117987 }, + { url = "https://files.pythonhosted.org/packages/94/36/6493a5cbc20c269a51186946947f3ca2eae687e05831289891027bd038c3/geventhttpclient-2.3.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f440cc704f8a9869848a109b2c401805c17c070539b2014e7b884ecfc8591e33", size = 123356 }, + { url = "https://files.pythonhosted.org/packages/2f/07/b66d9a13b97a7e59d84b4faf704113aa963aaf3a0f71c9138c8740d57d5c/geventhttpclient-2.3.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f10c62994f9052f23948c19de930b2d1f063240462c8bd7077c2b3290e61f4fa", size = 114460 }, + { url = "https://files.pythonhosted.org/packages/4e/72/1467b9e1ef63aecfe3b42333fb7607f66129dffaeca231f97e4be6f71803/geventhttpclient-2.3.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52c45d9f3dd9627844c12e9ca347258c7be585bed54046336220e25ea6eac155", size = 112808 }, + { url = "https://files.pythonhosted.org/packages/ce/ef/64894efd67cb3459074c734736ecacff398cd841a5538dc70e3e77d35500/geventhttpclient-2.3.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:77c1a2c6e3854bf87cd5588b95174640c8a881716bd07fa0d131d082270a6795", size = 122049 }, + { url = "https://files.pythonhosted.org/packages/c5/c8/1b13b4ea4bb88d7c2db56d070a52daf4757b3139afd83885e81455cb422f/geventhttpclient-2.3.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:ce649d4e25c2d56023471df0bf1e8e2ab67dfe4ff12ce3e8fe7e6fae30cd672a", size = 118755 }, + { url = "https://files.pythonhosted.org/packages/d1/06/95ac63fa1ee118a4d5824aa0a6b0dc3a2934a2f4ce695bf6747e1744d813/geventhttpclient-2.3.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:265d9f31b4ac8f688eebef0bd4c814ffb37a16f769ad0c8c8b8c24a84db8eab5", size = 128053 }, + { url = "https://files.pythonhosted.org/packages/8a/27/3d6dbbd128e1b965bae198bffa4b5552cd635397e3d2bbcc7d9592890ca9/geventhttpclient-2.3.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2de436a9d61dae877e4e811fb3e2594e2a1df1b18f4280878f318aef48a562b9", size = 117316 }, + { url = "https://files.pythonhosted.org/packages/ed/9a/8b65daf417ff982fa1928ebc6ebdfb081750d426f877f0056288aaa689e8/geventhttpclient-2.3.1-cp311-cp311-win32.whl", hash = "sha256:83e22178b9480b0a95edf0053d4f30b717d0b696b3c262beabe6964d9c5224b1", size = 47598 }, + { url = "https://files.pythonhosted.org/packages/ab/83/ed0d14787861cf30beddd3aadc29ad07d75555de43c629ba514ddd2978d0/geventhttpclient-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:97b072a282233384c1302a7dee88ad8bfedc916f06b1bc1da54f84980f1406a9", size = 48301 }, + { url = "https://files.pythonhosted.org/packages/82/ee/bf3d26170a518d2b1254f44202f2fa4490496b476ee24046ff6c34e79c08/geventhttpclient-2.3.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:e1c90abcc2735cd8dd2d2572a13da32f6625392dc04862decb5c6476a3ddee22", size = 71742 }, + { url = "https://files.pythonhosted.org/packages/77/72/bd64b2a491094a3fbf7f3c314bb3c3918afb652783a8a9db07b86072da35/geventhttpclient-2.3.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5deb41c2f51247b4e568c14964f59d7b8e537eff51900564c88af3200004e678", size = 52070 }, + { url = "https://files.pythonhosted.org/packages/85/96/e25becfde16c5551ba04ed2beac1f018e2efc70275ec19ae3765ff634ff2/geventhttpclient-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c6f1a56a66a90c4beae2f009b5e9d42db9a58ced165aa35441ace04d69cb7b37", size = 51650 }, + { url = "https://files.pythonhosted.org/packages/5d/b8/fe6e938a369b3742103d04e5771e1ec7b18c047ac30b06a8e9704e2d34fc/geventhttpclient-2.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ee6e741849c29e3129b1ec3828ac3a5e5dcb043402f852ea92c52334fb8cabf", size = 118507 }, + { url = "https://files.pythonhosted.org/packages/68/0b/381d01de049b02dc70addbcc1c8e24d15500bff6a9e89103c4aa8eb352c3/geventhttpclient-2.3.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d0972096a63b1ddaa73fa3dab2c7a136e3ab8bf7999a2f85a5dee851fa77cdd", size = 124061 }, + { url = "https://files.pythonhosted.org/packages/c6/e6/7c97b5bf41cc403b2936a0887a85550b3153aa4b60c0c5062c49cd6286f2/geventhttpclient-2.3.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00675ba682fb7d19d659c14686fa8a52a65e3f301b56c2a4ee6333b380dd9467", size = 115060 }, + { url = "https://files.pythonhosted.org/packages/45/1f/3e02464449c74a8146f27218471578c1dfabf18731cf047520b76e1b6331/geventhttpclient-2.3.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea77b67c186df90473416f4403839728f70ef6cf1689cec97b4f6bbde392a8a8", size = 113762 }, + { url = "https://files.pythonhosted.org/packages/4f/a4/08551776f7d6b219d6f73ca25be88806007b16af51a1dbfed7192528e1c3/geventhttpclient-2.3.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ddcc3f0fdffd9a3801e1005b73026202cffed8199863fdef9315bea9a860a032", size = 122018 }, + { url = "https://files.pythonhosted.org/packages/70/14/ba91417ac7cbce8d553f72c885a19c6b9d7f9dc7de81b7814551cf020a57/geventhttpclient-2.3.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:c9f1ef4ec048563cc621a47ff01a4f10048ff8b676d7a4d75e5433ed8e703e56", size = 118884 }, + { url = "https://files.pythonhosted.org/packages/7c/78/e1f2c30e11bda8347a74b3a7254f727ff53ea260244da77d76b96779a006/geventhttpclient-2.3.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:a364b30bec7a0a00dbe256e2b6807e4dc866bead7ac84aaa51ca5e2c3d15c258", size = 128224 }, + { url = "https://files.pythonhosted.org/packages/ac/2f/b7fd96e9cfa9d9719b0c9feb50b4cbb341d1940e34fd3305006efa8c3e33/geventhttpclient-2.3.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:25d255383d3d6a6fbd643bb51ae1a7e4f6f7b0dbd5f3225b537d0bd0432eaf39", size = 117758 }, + { url = "https://files.pythonhosted.org/packages/fb/e0/1384c9a76379ab257b75df92283797861dcae592dd98e471df254f87c635/geventhttpclient-2.3.1-cp312-cp312-win32.whl", hash = "sha256:ad0b507e354d2f398186dcb12fe526d0594e7c9387b514fb843f7a14fdf1729a", size = 47595 }, + { url = "https://files.pythonhosted.org/packages/54/e3/6b8dbb24e3941e20abbe7736e59290c5d4182057ea1d984d46c853208bcd/geventhttpclient-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:7924e0883bc2b177cfe27aa65af6bb9dd57f3e26905c7675a2d1f3ef69df7cca", size = 48271 }, + { url = "https://files.pythonhosted.org/packages/ee/9f/251b1b7e665523137a8711f0f0029196cf18b57741135f01aea80a56f16c/geventhttpclient-2.3.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:c31431e38df45b3c79bf3c9427c796adb8263d622bc6fa25e2f6ba916c2aad93", size = 49827 }, + { url = "https://files.pythonhosted.org/packages/74/c7/ad4c23de669191e1c83cfa28c51d3b50fc246d72e1ee40d4d5b330532492/geventhttpclient-2.3.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:855ab1e145575769b180b57accb0573a77cd6a7392f40a6ef7bc9a4926ebd77b", size = 54017 }, + { url = "https://files.pythonhosted.org/packages/04/7b/59fc8c8fbd10596abfc46dc103654e3d9676de64229d8eee4b0a4ac2e890/geventhttpclient-2.3.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a374aad77c01539e786d0c7829bec2eba034ccd45733c1bf9811ad18d2a8ecd", size = 58359 }, + { url = "https://files.pythonhosted.org/packages/94/b7/743552b0ecda75458c83d55d62937e29c9ee9a42598f57d4025d5de70004/geventhttpclient-2.3.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66c1e97460608304f400485ac099736fff3566d3d8db2038533d466f8cf5de5a", size = 54262 }, + { url = "https://files.pythonhosted.org/packages/18/60/10f6215b6cc76b5845a7f4b9c3d1f47d7ecd84ce8769b1e27e0482d605d7/geventhttpclient-2.3.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:4f843f81ee44ba4c553a1b3f73115e0ad8f00044023c24db29f5b1df3da08465", size = 48343 }, +] + +[[package]] +name = "greenlet" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/ff/df5fede753cc10f6a5be0931204ea30c35fa2f2ea7a35b25bdaf4fe40e46/greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467", size = 186022 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/90/5234a78dc0ef6496a6eb97b67a42a8e96742a56f7dc808cb954a85390448/greenlet-3.1.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:0bbae94a29c9e5c7e4a2b7f0aae5c17e8e90acbfd3bf6270eeba60c39fce3563", size = 271235 }, + { url = "https://files.pythonhosted.org/packages/7c/16/cd631fa0ab7d06ef06387135b7549fdcc77d8d859ed770a0d28e47b20972/greenlet-3.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fde093fb93f35ca72a556cf72c92ea3ebfda3d79fc35bb19fbe685853869a83", size = 637168 }, + { url = "https://files.pythonhosted.org/packages/2f/b1/aed39043a6fec33c284a2c9abd63ce191f4f1a07319340ffc04d2ed3256f/greenlet-3.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36b89d13c49216cadb828db8dfa6ce86bbbc476a82d3a6c397f0efae0525bdd0", size = 648826 }, + { url = "https://files.pythonhosted.org/packages/76/25/40e0112f7f3ebe54e8e8ed91b2b9f970805143efef16d043dfc15e70f44b/greenlet-3.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b6150a85e1b33b40b1464a3f9988dcc5251d6ed06842abff82e42632fac120", size = 644443 }, + { url = "https://files.pythonhosted.org/packages/fb/2f/3850b867a9af519794784a7eeed1dd5bc68ffbcc5b28cef703711025fd0a/greenlet-3.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93147c513fac16385d1036b7e5b102c7fbbdb163d556b791f0f11eada7ba65dc", size = 643295 }, + { url = "https://files.pythonhosted.org/packages/cf/69/79e4d63b9387b48939096e25115b8af7cd8a90397a304f92436bcb21f5b2/greenlet-3.1.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da7a9bff22ce038e19bf62c4dd1ec8391062878710ded0a845bcf47cc0200617", size = 599544 }, + { url = "https://files.pythonhosted.org/packages/46/1d/44dbcb0e6c323bd6f71b8c2f4233766a5faf4b8948873225d34a0b7efa71/greenlet-3.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b2795058c23988728eec1f36a4e5e4ebad22f8320c85f3587b539b9ac84128d7", size = 1125456 }, + { url = "https://files.pythonhosted.org/packages/e0/1d/a305dce121838d0278cee39d5bb268c657f10a5363ae4b726848f833f1bb/greenlet-3.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ed10eac5830befbdd0c32f83e8aa6288361597550ba669b04c48f0f9a2c843c6", size = 1149111 }, + { url = "https://files.pythonhosted.org/packages/96/28/d62835fb33fb5652f2e98d34c44ad1a0feacc8b1d3f1aecab035f51f267d/greenlet-3.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:77c386de38a60d1dfb8e55b8c1101d68c79dfdd25c7095d51fec2dd800892b80", size = 298392 }, + { url = "https://files.pythonhosted.org/packages/28/62/1c2665558618553c42922ed47a4e6d6527e2fa3516a8256c2f431c5d0441/greenlet-3.1.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e4d333e558953648ca09d64f13e6d8f0523fa705f51cae3f03b5983489958c70", size = 272479 }, + { url = "https://files.pythonhosted.org/packages/76/9d/421e2d5f07285b6e4e3a676b016ca781f63cfe4a0cd8eaecf3fd6f7a71ae/greenlet-3.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fc016b73c94e98e29af67ab7b9a879c307c6731a2c9da0db5a7d9b7edd1159", size = 640404 }, + { url = "https://files.pythonhosted.org/packages/e5/de/6e05f5c59262a584e502dd3d261bbdd2c97ab5416cc9c0b91ea38932a901/greenlet-3.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5e975ca70269d66d17dd995dafc06f1b06e8cb1ec1e9ed54c1d1e4a7c4cf26e", size = 652813 }, + { url = "https://files.pythonhosted.org/packages/49/93/d5f93c84241acdea15a8fd329362c2c71c79e1a507c3f142a5d67ea435ae/greenlet-3.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b2813dc3de8c1ee3f924e4d4227999285fd335d1bcc0d2be6dc3f1f6a318ec1", size = 648517 }, + { url = "https://files.pythonhosted.org/packages/15/85/72f77fc02d00470c86a5c982b8daafdf65d38aefbbe441cebff3bf7037fc/greenlet-3.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e347b3bfcf985a05e8c0b7d462ba6f15b1ee1c909e2dcad795e49e91b152c383", size = 647831 }, + { url = "https://files.pythonhosted.org/packages/f7/4b/1c9695aa24f808e156c8f4813f685d975ca73c000c2a5056c514c64980f6/greenlet-3.1.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e8f8c9cb53cdac7ba9793c276acd90168f416b9ce36799b9b885790f8ad6c0a", size = 602413 }, + { url = "https://files.pythonhosted.org/packages/76/70/ad6e5b31ef330f03b12559d19fda2606a522d3849cde46b24f223d6d1619/greenlet-3.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:62ee94988d6b4722ce0028644418d93a52429e977d742ca2ccbe1c4f4a792511", size = 1129619 }, + { url = "https://files.pythonhosted.org/packages/f4/fb/201e1b932e584066e0f0658b538e73c459b34d44b4bd4034f682423bc801/greenlet-3.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1776fd7f989fc6b8d8c8cb8da1f6b82c5814957264d1f6cf818d475ec2bf6395", size = 1155198 }, + { url = "https://files.pythonhosted.org/packages/12/da/b9ed5e310bb8b89661b80cbcd4db5a067903bbcd7fc854923f5ebb4144f0/greenlet-3.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:48ca08c771c268a768087b408658e216133aecd835c0ded47ce955381105ba39", size = 298930 }, + { url = "https://files.pythonhosted.org/packages/7d/ec/bad1ac26764d26aa1353216fcbfa4670050f66d445448aafa227f8b16e80/greenlet-3.1.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d", size = 274260 }, + { url = "https://files.pythonhosted.org/packages/66/d4/c8c04958870f482459ab5956c2942c4ec35cac7fe245527f1039837c17a9/greenlet-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79", size = 649064 }, + { url = "https://files.pythonhosted.org/packages/51/41/467b12a8c7c1303d20abcca145db2be4e6cd50a951fa30af48b6ec607581/greenlet-3.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa", size = 663420 }, + { url = "https://files.pythonhosted.org/packages/27/8f/2a93cd9b1e7107d5c7b3b7816eeadcac2ebcaf6d6513df9abaf0334777f6/greenlet-3.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441", size = 658035 }, + { url = "https://files.pythonhosted.org/packages/57/5c/7c6f50cb12be092e1dccb2599be5a942c3416dbcfb76efcf54b3f8be4d8d/greenlet-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36", size = 660105 }, + { url = "https://files.pythonhosted.org/packages/f1/66/033e58a50fd9ec9df00a8671c74f1f3a320564c6415a4ed82a1c651654ba/greenlet-3.1.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9", size = 613077 }, + { url = "https://files.pythonhosted.org/packages/19/c5/36384a06f748044d06bdd8776e231fadf92fc896bd12cb1c9f5a1bda9578/greenlet-3.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0", size = 1135975 }, + { url = "https://files.pythonhosted.org/packages/38/f9/c0a0eb61bdf808d23266ecf1d63309f0e1471f284300ce6dac0ae1231881/greenlet-3.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942", size = 1163955 }, + { url = "https://files.pythonhosted.org/packages/43/21/a5d9df1d21514883333fc86584c07c2b49ba7c602e670b174bd73cfc9c7f/greenlet-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01", size = 299655 }, + { url = "https://files.pythonhosted.org/packages/f3/57/0db4940cd7bb461365ca8d6fd53e68254c9dbbcc2b452e69d0d41f10a85e/greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1", size = 272990 }, + { url = "https://files.pythonhosted.org/packages/1c/ec/423d113c9f74e5e402e175b157203e9102feeb7088cee844d735b28ef963/greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff", size = 649175 }, + { url = "https://files.pythonhosted.org/packages/a9/46/ddbd2db9ff209186b7b7c621d1432e2f21714adc988703dbdd0e65155c77/greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a", size = 663425 }, + { url = "https://files.pythonhosted.org/packages/bc/f9/9c82d6b2b04aa37e38e74f0c429aece5eeb02bab6e3b98e7db89b23d94c6/greenlet-3.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e", size = 657736 }, + { url = "https://files.pythonhosted.org/packages/d9/42/b87bc2a81e3a62c3de2b0d550bf91a86939442b7ff85abb94eec3fc0e6aa/greenlet-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4", size = 660347 }, + { url = "https://files.pythonhosted.org/packages/37/fa/71599c3fd06336cdc3eac52e6871cfebab4d9d70674a9a9e7a482c318e99/greenlet-3.1.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e", size = 615583 }, + { url = "https://files.pythonhosted.org/packages/4e/96/e9ef85de031703ee7a4483489b40cf307f93c1824a02e903106f2ea315fe/greenlet-3.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1", size = 1133039 }, + { url = "https://files.pythonhosted.org/packages/87/76/b2b6362accd69f2d1889db61a18c94bc743e961e3cab344c2effaa4b4a25/greenlet-3.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c", size = 1160716 }, + { url = "https://files.pythonhosted.org/packages/1f/1b/54336d876186920e185066d8c3024ad55f21d7cc3683c856127ddb7b13ce/greenlet-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761", size = 299490 }, + { url = "https://files.pythonhosted.org/packages/5f/17/bea55bf36990e1638a2af5ba10c1640273ef20f627962cf97107f1e5d637/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011", size = 643731 }, + { url = "https://files.pythonhosted.org/packages/78/d2/aa3d2157f9ab742a08e0fd8f77d4699f37c22adfbfeb0c610a186b5f75e0/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13", size = 649304 }, + { url = "https://files.pythonhosted.org/packages/f1/8e/d0aeffe69e53ccff5a28fa86f07ad1d2d2d6537a9506229431a2a02e2f15/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475", size = 646537 }, + { url = "https://files.pythonhosted.org/packages/05/79/e15408220bbb989469c8871062c97c6c9136770657ba779711b90870d867/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b", size = 642506 }, + { url = "https://files.pythonhosted.org/packages/18/87/470e01a940307796f1d25f8167b551a968540fbe0551c0ebb853cb527dd6/greenlet-3.1.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822", size = 602753 }, + { url = "https://files.pythonhosted.org/packages/e2/72/576815ba674eddc3c25028238f74d7b8068902b3968cbe456771b166455e/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01", size = 1122731 }, + { url = "https://files.pythonhosted.org/packages/ac/38/08cc303ddddc4b3d7c628c3039a61a3aae36c241ed01393d00c2fd663473/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6", size = 1142112 }, +] + +[[package]] +name = "gunicorn" +version = "23.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029 }, +] + +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, +] + +[[package]] +name = "httpcore" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/56/78a38490b834fa0942cbe6d39bd8a7fd76316e8940319305a98d2b320366/httpcore-1.0.2.tar.gz", hash = "sha256:9fc092e4799b26174648e54b74ed5f683132a464e95643b226e00c2ed2fa6535", size = 81036 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/ba/78b0a99c4da0ff8b0f59defa2f13ca4668189b134bd9840b6202a93d9a0f/httpcore-1.0.2-py3-none-any.whl", hash = "sha256:096cc05bca73b8e459a1fc3dcf585148f63e534eae4339559c9b8a8d6399acc7", size = 76943 }, +] + +[[package]] +name = "httptools" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/6f/972f8eb0ea7d98a1c6be436e2142d51ad2a64ee18e02b0e7ff1f62171ab1/httptools-0.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3c73ce323711a6ffb0d247dcd5a550b8babf0f757e86a52558fe5b86d6fefcc0", size = 198780 }, + { url = "https://files.pythonhosted.org/packages/6a/b0/17c672b4bc5c7ba7f201eada4e96c71d0a59fbc185e60e42580093a86f21/httptools-0.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:345c288418f0944a6fe67be8e6afa9262b18c7626c3ef3c28adc5eabc06a68da", size = 103297 }, + { url = "https://files.pythonhosted.org/packages/92/5e/b4a826fe91971a0b68e8c2bd4e7db3e7519882f5a8ccdb1194be2b3ab98f/httptools-0.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deee0e3343f98ee8047e9f4c5bc7cedbf69f5734454a94c38ee829fb2d5fa3c1", size = 443130 }, + { url = "https://files.pythonhosted.org/packages/b0/51/ce61e531e40289a681a463e1258fa1e05e0be54540e40d91d065a264cd8f/httptools-0.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca80b7485c76f768a3bc83ea58373f8db7b015551117375e4918e2aa77ea9b50", size = 442148 }, + { url = "https://files.pythonhosted.org/packages/ea/9e/270b7d767849b0c96f275c695d27ca76c30671f8eb8cc1bab6ced5c5e1d0/httptools-0.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:90d96a385fa941283ebd231464045187a31ad932ebfa541be8edf5b3c2328959", size = 415949 }, + { url = "https://files.pythonhosted.org/packages/81/86/ced96e3179c48c6f656354e106934e65c8963d48b69be78f355797f0e1b3/httptools-0.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:59e724f8b332319e2875efd360e61ac07f33b492889284a3e05e6d13746876f4", size = 417591 }, + { url = "https://files.pythonhosted.org/packages/75/73/187a3f620ed3175364ddb56847d7a608a6fc42d551e133197098c0143eca/httptools-0.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:c26f313951f6e26147833fc923f78f95604bbec812a43e5ee37f26dc9e5a686c", size = 88344 }, + { url = "https://files.pythonhosted.org/packages/7b/26/bb526d4d14c2774fe07113ca1db7255737ffbb119315839af2065abfdac3/httptools-0.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f47f8ed67cc0ff862b84a1189831d1d33c963fb3ce1ee0c65d3b0cbe7b711069", size = 199029 }, + { url = "https://files.pythonhosted.org/packages/a6/17/3e0d3e9b901c732987a45f4f94d4e2c62b89a041d93db89eafb262afd8d5/httptools-0.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0614154d5454c21b6410fdf5262b4a3ddb0f53f1e1721cfd59d55f32138c578a", size = 103492 }, + { url = "https://files.pythonhosted.org/packages/b7/24/0fe235d7b69c42423c7698d086d4db96475f9b50b6ad26a718ef27a0bce6/httptools-0.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8787367fbdfccae38e35abf7641dafc5310310a5987b689f4c32cc8cc3ee975", size = 462891 }, + { url = "https://files.pythonhosted.org/packages/b1/2f/205d1f2a190b72da6ffb5f41a3736c26d6fa7871101212b15e9b5cd8f61d/httptools-0.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40b0f7fe4fd38e6a507bdb751db0379df1e99120c65fbdc8ee6c1d044897a636", size = 459788 }, + { url = "https://files.pythonhosted.org/packages/6e/4c/d09ce0eff09057a206a74575ae8f1e1e2f0364d20e2442224f9e6612c8b9/httptools-0.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40a5ec98d3f49904b9fe36827dcf1aadfef3b89e2bd05b0e35e94f97c2b14721", size = 433214 }, + { url = "https://files.pythonhosted.org/packages/3e/d2/84c9e23edbccc4a4c6f96a1b8d99dfd2350289e94f00e9ccc7aadde26fb5/httptools-0.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dacdd3d10ea1b4ca9df97a0a303cbacafc04b5cd375fa98732678151643d4988", size = 434120 }, + { url = "https://files.pythonhosted.org/packages/d0/46/4d8e7ba9581416de1c425b8264e2cadd201eb709ec1584c381f3e98f51c1/httptools-0.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:288cd628406cc53f9a541cfaf06041b4c71d751856bab45e3702191f931ccd17", size = 88565 }, + { url = "https://files.pythonhosted.org/packages/bb/0e/d0b71465c66b9185f90a091ab36389a7352985fe857e352801c39d6127c8/httptools-0.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df017d6c780287d5c80601dafa31f17bddb170232d85c066604d8558683711a2", size = 200683 }, + { url = "https://files.pythonhosted.org/packages/e2/b8/412a9bb28d0a8988de3296e01efa0bd62068b33856cdda47fe1b5e890954/httptools-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85071a1e8c2d051b507161f6c3e26155b5c790e4e28d7f236422dbacc2a9cc44", size = 104337 }, + { url = "https://files.pythonhosted.org/packages/9b/01/6fb20be3196ffdc8eeec4e653bc2a275eca7f36634c86302242c4fbb2760/httptools-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69422b7f458c5af875922cdb5bd586cc1f1033295aa9ff63ee196a87519ac8e1", size = 508796 }, + { url = "https://files.pythonhosted.org/packages/f7/d8/b644c44acc1368938317d76ac991c9bba1166311880bcc0ac297cb9d6bd7/httptools-0.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16e603a3bff50db08cd578d54f07032ca1631450ceb972c2f834c2b860c28ea2", size = 510837 }, + { url = "https://files.pythonhosted.org/packages/52/d8/254d16a31d543073a0e57f1c329ca7378d8924e7e292eda72d0064987486/httptools-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec4f178901fa1834d4a060320d2f3abc5c9e39766953d038f1458cb885f47e81", size = 485289 }, + { url = "https://files.pythonhosted.org/packages/5f/3c/4aee161b4b7a971660b8be71a92c24d6c64372c1ab3ae7f366b3680df20f/httptools-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb89ecf8b290f2e293325c646a211ff1c2493222798bb80a530c5e7502494f", size = 489779 }, + { url = "https://files.pythonhosted.org/packages/12/b7/5cae71a8868e555f3f67a50ee7f673ce36eac970f029c0c5e9d584352961/httptools-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:db78cb9ca56b59b016e64b6031eda5653be0589dba2b1b43453f6e8b405a0970", size = 88634 }, + { url = "https://files.pythonhosted.org/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660", size = 197214 }, + { url = "https://files.pythonhosted.org/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083", size = 102431 }, + { url = "https://files.pythonhosted.org/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3", size = 473121 }, + { url = "https://files.pythonhosted.org/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071", size = 473805 }, + { url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858 }, + { url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042 }, + { url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "huggingface-hub" +version = "0.29.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/b2/f8b3c9842a794e8203448725aefa02d7c9e0da42d5f22f4ed806057cc36e/huggingface_hub-0.29.2.tar.gz", hash = "sha256:590b29c0dcbd0ee4b7b023714dc1ad8563fe4a68a91463438b74e980d28afaf3", size = 389816 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/5f/088ff08dc41808fcd99d9972b9bcfa7e3a35e30e8b0a3155b57938f1611c/huggingface_hub-0.29.2-py3-none-any.whl", hash = "sha256:c56f20fca09ef19da84dcde2b76379ecdaddf390b083f59f166715584953307d", size = 468087 }, +] + +[[package]] +name = "humanfriendly" +version = "10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyreadline3", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794 }, +] + +[[package]] +name = "idna" +version = "3.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/69/3aaa69cb0748e33e644fda114c9abd3186ce369edd4fca11107e9f39c6a7/imageio-2.33.1-py3-none-any.whl", hash = "sha256:c5094c48ccf6b2e6da8b4061cd95e1209380afafcbeae4a4e280938cce227e1d", size = 313345 }, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, +] + +[[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 } + +[[package]] +name = "itsdangerous" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/a1/d3fb83e7a61fa0c0d3d08ad0a94ddbeff3731c05212617dff3a94e097f08/itsdangerous-2.1.2.tar.gz", hash = "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a", size = 56143 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/5f/447e04e828f47465eeab35b5d408b7ebaaaee207f48b7136c5a7267a30ae/itsdangerous-2.1.2-py3-none-any.whl", hash = "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44", size = 15749 }, +] + +[[package]] +name = "jinja2" +version = "3.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/55/39036716d19cab0747a5020fc7e907f362fbf48c984b14e62127f7e68e5d/jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369", size = 240245 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/80/3a54838c3fb461f6fec263ebf3a3a41771bd05190238de3486aae8540c36/jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d", size = 133271 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/40/d551139c85db202f1f384ba8bcf96aca2f329440a844f924c8a0040b6d02/joblib-1.3.2-py3-none-any.whl", hash = "sha256:ef4331c65f239985f3f2220ecc87db222f08fd22097a3dd5698f693875f8cbb9", size = 302207 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/56/cb02dcefdaab40df636b91e703b172966b444605a0ea313549f3ffc05bd3/kiwisolver-1.4.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:05703cf211d585109fcd72207a31bb170a0f22144d68298dc5e61b3c946518af", size = 127397 }, + { url = "https://files.pythonhosted.org/packages/0e/c1/d084f8edb26533a191415d5173157080837341f9a06af9dd1a75f727abb4/kiwisolver-1.4.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:146d14bebb7f1dc4d5fbf74f8a6cb15ac42baadee8912eb84ac0b3b2a3dc6ac3", size = 68125 }, + { url = "https://files.pythonhosted.org/packages/23/11/6fb190bae4b279d712a834e7b1da89f6dcff6791132f7399aa28a57c3565/kiwisolver-1.4.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6ef7afcd2d281494c0a9101d5c571970708ad911d028137cd558f02b851c08b4", size = 66211 }, + { url = "https://files.pythonhosted.org/packages/b3/13/5e9e52feb33e9e063f76b2c5eb09cb977f5bba622df3210081bfb26ec9a3/kiwisolver-1.4.5-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9eaa8b117dc8337728e834b9c6e2611f10c79e38f65157c4c38e9400286f5cb1", size = 1637145 }, + { url = "https://files.pythonhosted.org/packages/6f/40/4ab1fdb57fced80ce5903f04ae1aed7c1d5939dda4fd0c0aa526c12fe28a/kiwisolver-1.4.5-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ec20916e7b4cbfb1f12380e46486ec4bcbaa91a9c448b97023fde0d5bbf9e4ff", size = 1617849 }, + { url = "https://files.pythonhosted.org/packages/49/ca/61ef43bd0832c7253b370735b0c38972c140c8774889b884372a629a8189/kiwisolver-1.4.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:39b42c68602539407884cf70d6a480a469b93b81b7701378ba5e2328660c847a", size = 1400921 }, + { url = "https://files.pythonhosted.org/packages/68/6f/854f6a845c00b4257482468e08d8bc386f4929ee499206142378ba234419/kiwisolver-1.4.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aa12042de0171fad672b6c59df69106d20d5596e4f87b5e8f76df757a7c399aa", size = 1513009 }, + { url = "https://files.pythonhosted.org/packages/50/65/76f303377167d12eb7a9b423d6771b39fe5c4373e4a42f075805b1f581ae/kiwisolver-1.4.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a40773c71d7ccdd3798f6489aaac9eee213d566850a9533f8d26332d626b82c", size = 1444819 }, + { url = "https://files.pythonhosted.org/packages/7e/ee/98cdf9dde129551467138b6e18cc1cc901e75ecc7ffb898c6f49609f33b1/kiwisolver-1.4.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:19df6e621f6d8b4b9c4d45f40a66839294ff2bb235e64d2178f7522d9170ac5b", size = 1817054 }, + { url = "https://files.pythonhosted.org/packages/e6/5b/ab569016ec4abc7b496f6cb8a3ab511372c99feb6a23d948cda97e0db6da/kiwisolver-1.4.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:83d78376d0d4fd884e2c114d0621624b73d2aba4e2788182d286309ebdeed770", size = 1918613 }, + { url = "https://files.pythonhosted.org/packages/93/ac/39b9f99d2474b1ac7af1ddfe5756ddf9b6a8f24c5f3a32cd4c010317fc6b/kiwisolver-1.4.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e391b1f0a8a5a10ab3b9bb6afcfd74f2175f24f8975fb87ecae700d1503cdee0", size = 1872650 }, + { url = "https://files.pythonhosted.org/packages/40/5b/be568548266516b114d1776120281ea9236c732fb6032a1f8f3b1e5e921c/kiwisolver-1.4.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:852542f9481f4a62dbb5dd99e8ab7aedfeb8fb6342349a181d4036877410f525", size = 1827415 }, + { url = "https://files.pythonhosted.org/packages/d4/80/c0c13d2a17a12937a19ef378bf35e94399fd171ed6ec05bcee0f038e1eaf/kiwisolver-1.4.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59edc41b24031bc25108e210c0def6f6c2191210492a972d585a06ff246bb79b", size = 1838094 }, + { url = "https://files.pythonhosted.org/packages/70/d1/5ab93ee00ca5af708929cc12fbe665b6f1ed4ad58088e70dc00e87e0d107/kiwisolver-1.4.5-cp310-cp310-win32.whl", hash = "sha256:a6aa6315319a052b4ee378aa171959c898a6183f15c1e541821c5c59beaa0238", size = 46585 }, + { url = "https://files.pythonhosted.org/packages/4a/a1/8a9c9be45c642fa12954855d8b3a02d9fd8551165a558835a19508fec2e6/kiwisolver-1.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:d0ef46024e6a3d79c01ff13801cb19d0cad7fd859b15037aec74315540acc276", size = 56095 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/9f/0a/fa56a0fdee5da2b4c79899c0f6bd1aefb29d9438c2d66430e78793571c6b/kiwisolver-1.4.5-cp311-cp311-win32.whl", hash = "sha256:bb86433b1cfe686da83ce32a9d3a8dd308e85c76b60896d58f082136f10bffac", size = 46649 }, + { url = "https://files.pythonhosted.org/packages/1e/37/d3c2d4ba2719059a0f12730947bbe1ad5ee8bff89e8c35319dcb2c9ddb4c/kiwisolver-1.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:6c08e1312a9cf1074d17b17728d3dfce2a5125b2d791527f33ffbe805200a355", size = 56116 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/a3/60/4f0fd50b08f5be536ea0cef518ac7255d9dab43ca40f3b93b60e3ddf80dd/kiwisolver-1.4.5-cp312-cp312-win32.whl", hash = "sha256:72d40b33e834371fd330fb1472ca19d9b8327acb79a5821d4008391db8e29f20", size = 46473 }, + { url = "https://files.pythonhosted.org/packages/63/50/2746566bdf4a6a842d117367d05c90cfb87ac04e9e2845aa1fa21f071362/kiwisolver-1.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:2c5674c4e74d939b9d91dda0fae10597ac7521768fec9e399c70a1f27e2ea2d9", size = 56004 }, +] + +[[package]] +name = "lazy-loader" +version = "0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/3a/1630a735bfdf9eb857a3b9a53317a1e1658ea97a1b4b39dcb0f71dae81f8/lazy_loader-0.3.tar.gz", hash = "sha256:3b68898e34f5b2a29daaaac172c6555512d0f32074f147e2254e4a6d9d838f37", size = 12268 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/c3/65b3814e155836acacf720e5be3b5757130346670ac454fee29d3eda1381/lazy_loader-0.3-py3-none-any.whl", hash = "sha256:1e9e76ee8631e264c62ce10006718e80b2cfc74340d17d1031e0f84af7478554", size = 9087 }, +] + +[[package]] +name = "locust" +version = "2.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "configargparse" }, + { name = "flask" }, + { name = "flask-cors" }, + { name = "flask-login" }, + { name = "gevent", marker = "python_full_version != '3.13.*'" }, + { name = "geventhttpclient" }, + { name = "msgpack" }, + { name = "psutil" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "pyzmq" }, + { name = "requests" }, + { name = "setuptools" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/d4/873b1415c8a667982c5f229c6b74abed9fe0ead29ad87d862e5116ea2679/locust-2.33.0.tar.gz", hash = "sha256:ba291b7ab2349cc2db540adb8888bc93feb89ea4e4e10d80b935e5065091e8e9", size = 2237622 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/e5/57b58d9a545fbb2981de59ca25534d5ab4abb6742e6400bb49576ecab5ec/locust-2.33.0-py3-none-any.whl", hash = "sha256:77fcc5cc35cceee5e12d99f5bb23bc441d145bdef6967c2e93d6e4d93451553e", size = 2254520 }, +] + +[[package]] +name = "machine-learning" +version = "1.129.0" +source = { editable = "." } +dependencies = [ + { name = "aiocache" }, + { name = "fastapi" }, + { name = "ftfy" }, + { name = "gunicorn" }, + { name = "huggingface-hub" }, + { name = "insightface" }, + { name = "opencv-python-headless" }, + { name = "orjson" }, + { name = "pillow" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "rich" }, + { name = "tokenizers" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.optional-dependencies] +armnn = [ + { name = "onnxruntime" }, +] +cpu = [ + { name = "onnxruntime" }, +] +cuda = [ + { name = "onnxruntime-gpu" }, +] +openvino = [ + { name = "onnxruntime-openvino" }, +] +rknn = [ + { name = "onnxruntime" }, + { name = "rknn-toolkit-lite2" }, +] + +[package.dev-dependencies] +dev = [ + { name = "black" }, + { name = "httpx" }, + { name = "locust" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, + { name = "ruff" }, + { name = "types-pyyaml" }, + { name = "types-requests" }, + { name = "types-setuptools" }, + { name = "types-simplejson" }, + { name = "types-ujson" }, +] +lint = [ + { name = "black" }, + { name = "mypy" }, + { name = "ruff" }, + { name = "types-pyyaml" }, + { name = "types-requests" }, + { name = "types-setuptools" }, + { name = "types-simplejson" }, + { name = "types-ujson" }, +] +test = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, +] +types = [ + { name = "types-pyyaml" }, + { name = "types-requests" }, + { name = "types-setuptools" }, + { name = "types-simplejson" }, + { name = "types-ujson" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiocache", specifier = ">=0.12.1,<1.0" }, + { name = "fastapi", specifier = ">=0.95.2,<1.0" }, + { name = "ftfy", specifier = ">=6.1.1" }, + { name = "gunicorn", specifier = ">=21.1.0" }, + { name = "huggingface-hub", specifier = ">=0.20.1,<1.0" }, + { name = "insightface", specifier = ">=0.7.3,<1.0" }, + { name = "onnxruntime", marker = "extra == 'armnn'", specifier = ">=1.15.0,<2" }, + { name = "onnxruntime", marker = "extra == 'cpu'", specifier = ">=1.15.0,<2" }, + { name = "onnxruntime", marker = "extra == 'rknn'", specifier = ">=1.15.0,<2" }, + { name = "onnxruntime-gpu", marker = "extra == 'cuda'", specifier = ">=1.17.0,<2", index = "https://aiinfra.pkgs.visualstudio.com/PublicPackages/_packaging/onnxruntime-cuda-12/pypi/simple/" }, + { name = "onnxruntime-openvino", marker = "extra == 'openvino'", specifier = ">=1.17.1,<1.19.0" }, + { name = "opencv-python-headless", specifier = ">=4.7.0.72,<5.0" }, + { name = "orjson", specifier = ">=3.9.5" }, + { name = "pillow", specifier = ">=9.5.0,<11.0" }, + { name = "pydantic", specifier = ">=2.0.0,<3" }, + { name = "pydantic-settings", specifier = ">=2.5.2,<3" }, + { name = "python-multipart", specifier = ">=0.0.6,<1.0" }, + { name = "rich", specifier = ">=13.4.2" }, + { name = "rknn-toolkit-lite2", marker = "extra == 'rknn'", specifier = ">=2.3.0,<3" }, + { name = "tokenizers", specifier = ">=0.15.0,<1.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.22.0,<1.0" }, +] +provides-extras = ["cpu", "cuda", "openvino", "armnn", "rknn"] + +[package.metadata.requires-dev] +dev = [ + { name = "black", specifier = ">=23.3.0" }, + { name = "httpx", specifier = ">=0.24.1" }, + { name = "locust", specifier = ">=2.15.1" }, + { name = "mypy", specifier = ">=1.3.0" }, + { name = "pytest", specifier = ">=7.3.1" }, + { name = "pytest-asyncio", specifier = ">=0.21.0" }, + { name = "pytest-cov", specifier = ">=4.1.0" }, + { name = "pytest-mock", specifier = ">=3.11.1" }, + { name = "ruff", specifier = ">=0.0.272" }, + { name = "types-pyyaml", specifier = ">=6.0.12.20241230" }, + { name = "types-requests", specifier = ">=2.32.0.20250306" }, + { name = "types-setuptools", specifier = ">=75.8.2.20250305" }, + { name = "types-simplejson", specifier = ">=3.20.0.20250218" }, + { name = "types-ujson", specifier = ">=5.10.0.20240515" }, +] +lint = [ + { name = "black", specifier = ">=23.3.0" }, + { name = "mypy", specifier = ">=1.3.0" }, + { name = "ruff", specifier = ">=0.0.272" }, + { name = "types-pyyaml", specifier = ">=6.0.12.20241230" }, + { name = "types-requests", specifier = ">=2.32.0.20250306" }, + { name = "types-setuptools", specifier = ">=75.8.2.20250305" }, + { name = "types-simplejson", specifier = ">=3.20.0.20250218" }, + { name = "types-ujson", specifier = ">=5.10.0.20240515" }, +] +test = [ + { name = "httpx", specifier = ">=0.24.1" }, + { name = "pytest", specifier = ">=7.3.1" }, + { name = "pytest-asyncio", specifier = ">=0.21.0" }, + { name = "pytest-cov", specifier = ">=4.1.0" }, + { name = "pytest-mock", specifier = ">=3.11.1" }, +] +types = [ + { name = "types-pyyaml", specifier = ">=6.0.12.20241230" }, + { name = "types-requests", specifier = ">=2.32.0.20250306" }, + { name = "types-setuptools", specifier = ">=75.8.2.20250305" }, + { name = "types-simplejson", specifier = ">=3.20.0.20250218" }, + { name = "types-ujson", specifier = ">=5.10.0.20240515" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + +[[package]] +name = "markupsafe" +version = "2.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/7c/59a3248f411813f8ccba92a55feaac4bf360d29e2ff05ee7d8e1ef2d7dbf/MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad", size = 19132 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/1d/713d443799d935f4d26a4f1510c9e61b1d288592fb869845e5cc92a1e055/MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa", size = 17846 }, + { url = "https://files.pythonhosted.org/packages/f7/9c/86cbd8e0e1d81f0ba420f20539dd459c50537c7751e28102dbfee2b6f28c/MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57", size = 13720 }, + { url = "https://files.pythonhosted.org/packages/a6/56/f1d4ee39e898a9e63470cbb7fae1c58cce6874f25f54220b89213a47f273/MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f", size = 26498 }, + { url = "https://files.pythonhosted.org/packages/12/b3/d9ed2c0971e1435b8a62354b18d3060b66c8cb1d368399ec0b9baa7c0ee5/MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52", size = 25691 }, + { url = "https://files.pythonhosted.org/packages/bf/b7/c5ba9b7ad9ad21fc4a60df226615cf43ead185d328b77b0327d603d00cc5/MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00", size = 25366 }, + { url = "https://files.pythonhosted.org/packages/71/61/f5673d7aac2cf7f203859008bb3fc2b25187aa330067c5e9955e5c5ebbab/MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6", size = 30505 }, + { url = "https://files.pythonhosted.org/packages/47/26/932140621773bfd4df3223fbdd9e78de3477f424f0d2987c313b1cb655ff/MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779", size = 29616 }, + { url = "https://files.pythonhosted.org/packages/3c/c8/74d13c999cbb49e3460bf769025659a37ef4a8e884de629720ab4e42dcdb/MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7", size = 29891 }, + { url = "https://files.pythonhosted.org/packages/96/e4/4db3b1abc5a1fe7295aa0683eafd13832084509c3b8236f3faf8dd4eff75/MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431", size = 16525 }, + { url = "https://files.pythonhosted.org/packages/84/a8/c4aebb8a14a1d39d5135eb8233a0b95831cdc42c4088358449c3ed657044/MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559", size = 17083 }, + { url = "https://files.pythonhosted.org/packages/fe/09/c31503cb8150cf688c1534a7135cc39bb9092f8e0e6369ec73494d16ee0e/MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c", size = 17862 }, + { url = "https://files.pythonhosted.org/packages/c0/c7/171f5ac6b065e1425e8fabf4a4dfbeca76fd8070072c6a41bd5c07d90d8b/MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575", size = 13738 }, + { url = "https://files.pythonhosted.org/packages/a2/f7/9175ad1b8152092f7c3b78c513c1bdfe9287e0564447d1c2d3d1a2471540/MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee", size = 28891 }, + { url = "https://files.pythonhosted.org/packages/fe/21/2eff1de472ca6c99ec3993eab11308787b9879af9ca8bbceb4868cf4f2ca/MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2", size = 28096 }, + { url = "https://files.pythonhosted.org/packages/f4/a0/103f94793c3bf829a18d2415117334ece115aeca56f2df1c47fa02c6dbd6/MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9", size = 27631 }, + { url = "https://files.pythonhosted.org/packages/43/70/f24470f33b2035b035ef0c0ffebf57006beb2272cf3df068fc5154e04ead/MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc", size = 33863 }, + { url = "https://files.pythonhosted.org/packages/32/d4/ce98c4ca713d91c4a17c1a184785cc00b9e9c25699d618956c2b9999500a/MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9", size = 32591 }, + { url = "https://files.pythonhosted.org/packages/bb/82/f88ccb3ca6204a4536cf7af5abdad7c3657adac06ab33699aa67279e0744/MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac", size = 33186 }, + { url = "https://files.pythonhosted.org/packages/44/53/93405d37bb04a10c43b1bdd6f548097478d494d7eadb4b364e3e1337f0cc/MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb", size = 16537 }, + { url = "https://files.pythonhosted.org/packages/be/bb/08b85bc194034efbf572e70c3951549c8eca0ada25363afc154386b5390a/MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686", size = 17089 }, + { url = "https://files.pythonhosted.org/packages/89/5a/ee546f2aa73a1d6fcfa24272f356fe06d29acca81e76b8d32ca53e429a2e/MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc", size = 17849 }, + { url = "https://files.pythonhosted.org/packages/3a/72/9f683a059bde096776e8acf9aa34cbbba21ddc399861fe3953790d4f2cde/MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823", size = 13700 }, + { url = "https://files.pythonhosted.org/packages/9d/78/92f15eb9b1e8f1668a9787ba103cf6f8d19a9efed8150245404836145c24/MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11", size = 29319 }, + { url = "https://files.pythonhosted.org/packages/51/94/9a04085114ff2c24f7424dbc890a281d73c5a74ea935dc2e69c66a3bd558/MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd", size = 28314 }, + { url = "https://files.pythonhosted.org/packages/ec/53/fcb3214bd370185e223b209ce6bb010fb887ea57173ca4f75bd211b24e10/MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939", size = 27696 }, + { url = "https://files.pythonhosted.org/packages/e7/33/54d29854716725d7826079b8984dd235fac76dab1c32321e555d493e61f5/MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c", size = 33746 }, + { url = "https://files.pythonhosted.org/packages/11/40/ea7f85e2681d29bc9301c757257de561923924f24de1802d9c3baa396bb4/MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c", size = 32131 }, + { url = "https://files.pythonhosted.org/packages/41/f1/bc770c37ecd58638c18f8ec85df205dacb818ccf933692082fd93010a4bc/MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1", size = 32878 }, + { url = "https://files.pythonhosted.org/packages/49/74/bf95630aab0a9ed6a67556cd4e54f6aeb0e74f4cb0fd2f229154873a4be4/MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007", size = 16426 }, + { url = "https://files.pythonhosted.org/packages/44/44/dbaf65876e258facd65f586dde158387ab89963e7f2235551afc9c2e24c2/MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb", size = 16979 }, +] + +[[package]] +name = "matplotlib" +version = "3.8.2" +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/fb/ab/38a0e94cb01dacb50f06957c2bed1c83b8f9dac6618988a37b2487862944/matplotlib-3.8.2.tar.gz", hash = "sha256:01a978b871b881ee76017152f1f1a0cbf6bd5f7b8ff8c96df0df1bd57d8755a1", size = 35866957 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/d0/fc5f6796a1956f5b9a33555611d01a3cec038f000c3d70ecb051b1631ac4/matplotlib-3.8.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:09796f89fb71a0c0e1e2f4bdaf63fb2cefc84446bb963ecdeb40dfee7dfa98c7", size = 7590640 }, + { url = "https://files.pythonhosted.org/packages/57/44/007b592809f50883c910db9ec4b81b16dfa0136407250fb581824daabf03/matplotlib-3.8.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6f9c6976748a25e8b9be51ea028df49b8e561eed7809146da7a47dbecebab367", size = 7484350 }, + { url = "https://files.pythonhosted.org/packages/01/87/c7b24f3048234fe10184560263be2173311376dc3d1fa329de7f012d6ce5/matplotlib-3.8.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b78e4f2cedf303869b782071b55fdde5987fda3038e9d09e58c91cc261b5ad18", size = 11382388 }, + { url = "https://files.pythonhosted.org/packages/19/e5/a4ea514515f270224435c69359abb7a3d152ed31b9ee3ba5e63017461945/matplotlib-3.8.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e208f46cf6576a7624195aa047cb344a7f802e113bb1a06cfd4bee431de5e31", size = 11611959 }, + { url = "https://files.pythonhosted.org/packages/09/23/ab5a562c9acb81e351b084bea39f65b153918417fb434619cf5a19f44a55/matplotlib-3.8.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:46a569130ff53798ea5f50afce7406e91fdc471ca1e0e26ba976a8c734c9427a", size = 9536938 }, + { url = "https://files.pythonhosted.org/packages/46/37/b5e27ab30ecc0a3694c8a78287b5ef35dad0c3095c144fcc43081170bfd6/matplotlib-3.8.2-cp310-cp310-win_amd64.whl", hash = "sha256:830f00640c965c5b7f6bc32f0d4ce0c36dfe0379f7dd65b07a00c801713ec40a", size = 7643836 }, + { url = "https://files.pythonhosted.org/packages/a9/0d/53afb186adafc7326d093b8333e8a79974c495095771659f4304626c4bc7/matplotlib-3.8.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d86593ccf546223eb75a39b44c32788e6f6440d13cfc4750c1c15d0fcb850b63", size = 7593458 }, + { url = "https://files.pythonhosted.org/packages/ce/25/a557ee10ac9dce1300850024707ce1850a6958f1673a9194be878b99d631/matplotlib-3.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9a5430836811b7652991939012f43d2808a2db9b64ee240387e8c43e2e5578c8", size = 7486840 }, + { url = "https://files.pythonhosted.org/packages/e7/3d/72712b3895ee180f6e342638a8591c31912fbcc09ce9084cc256da16d0a0/matplotlib-3.8.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9576723858a78751d5aacd2497b8aef29ffea6d1c95981505877f7ac28215c6", size = 11387332 }, + { url = "https://files.pythonhosted.org/packages/92/1a/cd3e0c90d1a763ad90073e13b189b4702f11becf4e71dbbad70a7a149811/matplotlib-3.8.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ba9cbd8ac6cf422f3102622b20f8552d601bf8837e49a3afed188d560152788", size = 11616911 }, + { url = "https://files.pythonhosted.org/packages/78/4a/bad239071477305a3758eb4810615e310a113399cddd7682998be9f01e97/matplotlib-3.8.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:03f9d160a29e0b65c0790bb07f4f45d6a181b1ac33eb1bb0dd225986450148f0", size = 9549260 }, + { url = "https://files.pythonhosted.org/packages/26/5a/27fd341e4510257789f19a4b4be8bb90d1113b8f176c3dab562b4f21466e/matplotlib-3.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:3773002da767f0a9323ba1a9b9b5d00d6257dbd2a93107233167cfb581f64717", size = 7645742 }, + { url = "https://files.pythonhosted.org/packages/e4/1b/864d28d5a72d586ac137f4ca54d5afc8b869720e30d508dbd9adcce4d231/matplotlib-3.8.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:4c318c1e95e2f5926fba326f68177dee364aa791d6df022ceb91b8221bd0a627", size = 7590988 }, + { url = "https://files.pythonhosted.org/packages/9a/b0/dd2b60f2dd90fbc21d1d3129c36a453c322d7995d5e3589f5b3c59ee528d/matplotlib-3.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:091275d18d942cf1ee9609c830a1bc36610607d8223b1b981c37d5c9fc3e46a4", size = 7483594 }, + { url = "https://files.pythonhosted.org/packages/33/da/9942533ad9f96753bde0e5a5d48eacd6c21de8ea1ad16570e31bda8a017f/matplotlib-3.8.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b0f3b8ea0e99e233a4bcc44590f01604840d833c280ebb8fe5554fd3e6cfe8d", size = 11380843 }, + { url = "https://files.pythonhosted.org/packages/fc/52/bfd36eb4745a3b21b3946c2c3a15679b620e14574fe2b98e9451b65ef578/matplotlib-3.8.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7b1704a530395aaf73912be741c04d181f82ca78084fbd80bc737be04848331", size = 11604608 }, + { url = "https://files.pythonhosted.org/packages/6d/8c/0cdfbf604d4ea3dfa77435176c51e233cc408ad8f3efbf8d2c9f57cbdafb/matplotlib-3.8.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:533b0e3b0c6768eef8cbe4b583731ce25a91ab54a22f830db2b031e83cca9213", size = 9545252 }, + { url = "https://files.pythonhosted.org/packages/2e/51/c77a14869b7eb9d6fb440e811b754fc3950d6868c38ace57d0632b674415/matplotlib-3.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:0f4fc5d72b75e2c18e55eb32292659cf731d9d5b312a6eb036506304f4675630", size = 7645067 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198 }, +] + +[[package]] +name = "msgpack" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/d5/5662032db1571110b5b51647aed4b56dfbd01bfae789fa566a2be1f385d1/msgpack-1.0.7.tar.gz", hash = "sha256:572efc93db7a4d27e404501975ca6d2d9775705c2d922390d878fcf768d92c87", size = 166311 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/3a/2e2e902afcd751738e38d88af976fc4010b16e8e821945f4cbf32f75f9c3/msgpack-1.0.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04ad6069c86e531682f9e1e71b71c1c3937d6014a7c3e9edd2aa81ad58842862", size = 304827 }, + { url = "https://files.pythonhosted.org/packages/86/a6/490792a524a82e855bdf3885ecb73d7b3a0b17744b3cf4a40aea13ceca38/msgpack-1.0.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cca1b62fe70d761a282496b96a5e51c44c213e410a964bdffe0928e611368329", size = 234959 }, + { url = "https://files.pythonhosted.org/packages/ad/72/d39ed43bfb2ec6968d768318477adb90c474bdc59b2437170c6697ee4115/msgpack-1.0.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e50ebce52f41370707f1e21a59514e3375e3edd6e1832f5e5235237db933c98b", size = 231970 }, + { url = "https://files.pythonhosted.org/packages/a2/90/2d769e693654f036acfb462b54dacb3ae345699999897ca34f6bd9534fe9/msgpack-1.0.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a7b4f35de6a304b5533c238bee86b670b75b03d31b7797929caa7a624b5dda6", size = 522440 }, + { url = "https://files.pythonhosted.org/packages/46/95/d0440400485eab1bf50f1efe5118967b539f3191d994c3dfc220657594cd/msgpack-1.0.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28efb066cde83c479dfe5a48141a53bc7e5f13f785b92ddde336c716663039ee", size = 530797 }, + { url = "https://files.pythonhosted.org/packages/76/33/35df717bc095c6e938b3c65ed117b95048abc24d1614427685123fb2f0af/msgpack-1.0.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4cb14ce54d9b857be9591ac364cb08dc2d6a5c4318c1182cb1d02274029d590d", size = 520372 }, + { url = "https://files.pythonhosted.org/packages/af/d1/abbdd58a43827fbec5d98427a7a535c620890289b9d927154465313d6967/msgpack-1.0.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b573a43ef7c368ba4ea06050a957c2a7550f729c31f11dd616d2ac4aba99888d", size = 527287 }, + { url = "https://files.pythonhosted.org/packages/0c/ac/66625b05091b97ca2c7418eb2d2af152f033d969519f9315556a4ed800fe/msgpack-1.0.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ccf9a39706b604d884d2cb1e27fe973bc55f2890c52f38df742bc1d79ab9f5e1", size = 560715 }, + { url = "https://files.pythonhosted.org/packages/de/4e/a0e8611f94bac32d2c1c4ad05bb1c0ae61132e3398e0b44a93e6d7830968/msgpack-1.0.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cb70766519500281815dfd7a87d3a178acf7ce95390544b8c90587d76b227681", size = 532614 }, + { url = "https://files.pythonhosted.org/packages/9b/07/0b3f089684ca330602b2994248eda2898a7232e4b63882b9271164ef672e/msgpack-1.0.7-cp310-cp310-win32.whl", hash = "sha256:b610ff0f24e9f11c9ae653c67ff8cc03c075131401b3e5ef4b82570d1728f8a9", size = 216340 }, + { url = "https://files.pythonhosted.org/packages/4b/14/c62fbc8dff118f1558e43b9469d56a1f37bbb35febadc3163efaedd01500/msgpack-1.0.7-cp310-cp310-win_amd64.whl", hash = "sha256:a40821a89dc373d6427e2b44b572efc36a2778d3f543299e2f24eb1a5de65415", size = 222828 }, + { url = "https://files.pythonhosted.org/packages/f9/b3/309de40dc7406b7f3492332c5ee2b492a593c2a9bb97ea48ebf2f5279999/msgpack-1.0.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:576eb384292b139821c41995523654ad82d1916da6a60cff129c715a6223ea84", size = 305096 }, + { url = "https://files.pythonhosted.org/packages/15/56/a677cd761a2cefb2e3ffe7e684633294dccb161d78e8ea6da9277e45b4a2/msgpack-1.0.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:730076207cb816138cf1af7f7237b208340a2c5e749707457d70705715c93b93", size = 235210 }, + { url = "https://files.pythonhosted.org/packages/f5/4e/1ab4a982cbd90f988e49f849fc1212f2c04a59870c59daabf8950617e2aa/msgpack-1.0.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:85765fdf4b27eb5086f05ac0491090fc76f4f2b28e09d9350c31aac25a5aaff8", size = 231952 }, + { url = "https://files.pythonhosted.org/packages/6d/74/bd02044eb628c7361ad2bd8c1a6147af5c6c2bbceb77b3b1da20f4a8a9c5/msgpack-1.0.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3476fae43db72bd11f29a5147ae2f3cb22e2f1a91d575ef130d2bf49afd21c46", size = 549511 }, + { url = "https://files.pythonhosted.org/packages/df/09/dee50913ba5cc047f7fd7162f09453a676e7935c84b3bf3a398e12108677/msgpack-1.0.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d4c80667de2e36970ebf74f42d1088cc9ee7ef5f4e8c35eee1b40eafd33ca5b", size = 557980 }, + { url = "https://files.pythonhosted.org/packages/26/a5/78a7d87f5f8ffe4c32167afa15d4957db649bab4822f909d8d765339bbab/msgpack-1.0.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b0bf0effb196ed76b7ad883848143427a73c355ae8e569fa538365064188b8e", size = 545547 }, + { url = "https://files.pythonhosted.org/packages/d4/53/698c10913947f97f6fe7faad86a34e6aa1b66cea2df6f99105856bd346d9/msgpack-1.0.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f9a7c509542db4eceed3dcf21ee5267ab565a83555c9b88a8109dcecc4709002", size = 554669 }, + { url = "https://files.pythonhosted.org/packages/f5/3f/9730c6cb574b15d349b80cd8523a7df4b82058528339f952ea1c32ac8a10/msgpack-1.0.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:84b0daf226913133f899ea9b30618722d45feffa67e4fe867b0b5ae83a34060c", size = 583353 }, + { url = "https://files.pythonhosted.org/packages/4c/bc/dc184d943692671149848438fb3bed3a3de288ce7998cb91bc98f40f201b/msgpack-1.0.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ec79ff6159dffcc30853b2ad612ed572af86c92b5168aa3fc01a67b0fa40665e", size = 557455 }, + { url = "https://files.pythonhosted.org/packages/cf/7b/1bc69d4a56c8d2f4f2dfbe4722d40344af9a85b6fb3b09cfb350ba6a42f6/msgpack-1.0.7-cp311-cp311-win32.whl", hash = "sha256:3e7bf4442b310ff154b7bb9d81eb2c016b7d597e364f97d72b1acc3817a0fdc1", size = 216367 }, + { url = "https://files.pythonhosted.org/packages/b4/3d/c8dd23050eefa3d9b9c5b8329ed3308c2f2f80f65825e9ea4b7fa621cdab/msgpack-1.0.7-cp311-cp311-win_amd64.whl", hash = "sha256:3f0c8c6dfa6605ab8ff0611995ee30d4f9fcff89966cf562733b4008a3d60d82", size = 222860 }, + { url = "https://files.pythonhosted.org/packages/d7/47/20dff6b4512cf3575550c8801bc53fe7d540f4efef9c5c37af51760fcdcf/msgpack-1.0.7-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f0936e08e0003f66bfd97e74ee530427707297b0d0361247e9b4f59ab78ddc8b", size = 305759 }, + { url = "https://files.pythonhosted.org/packages/6f/8a/34f1726d2c9feccec3d946776e9bce8f20ae09d8b91899fc20b296c942af/msgpack-1.0.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:98bbd754a422a0b123c66a4c341de0474cad4a5c10c164ceed6ea090f3563db4", size = 235330 }, + { url = "https://files.pythonhosted.org/packages/9c/f6/e64c72577d6953789c3cb051b059a4b56317056b3c65013952338ed8a34e/msgpack-1.0.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b291f0ee7961a597cbbcc77709374087fa2a9afe7bdb6a40dbbd9b127e79afee", size = 232537 }, + { url = "https://files.pythonhosted.org/packages/89/75/1ed3a96e12941873fd957e016cc40c0c178861a872bd45e75b9a188eb422/msgpack-1.0.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebbbba226f0a108a7366bf4b59bf0f30a12fd5e75100c630267d94d7f0ad20e5", size = 546561 }, + { url = "https://files.pythonhosted.org/packages/e5/0a/c6a1390f9c6a31da0fecbbfdb86b1cb39ad302d9e24f9cca3d9e14c364f0/msgpack-1.0.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e2d69948e4132813b8d1131f29f9101bc2c915f26089a6d632001a5c1349672", size = 559009 }, + { url = "https://files.pythonhosted.org/packages/a5/74/99f6077754665613ea1f37b3d91c10129f6976b7721ab4d0973023808e5a/msgpack-1.0.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdf38ba2d393c7911ae989c3bbba510ebbcdf4ecbdbfec36272abe350c454075", size = 543882 }, + { url = "https://files.pythonhosted.org/packages/9c/7e/dc0dc8de2bf27743b31691149258f9b1bd4bf3c44c105df3df9b97081cd1/msgpack-1.0.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:993584fc821c58d5993521bfdcd31a4adf025c7d745bbd4d12ccfecf695af5ba", size = 546949 }, + { url = "https://files.pythonhosted.org/packages/78/61/91bae9474def032f6c333d62889bbeda9e1554c6b123375ceeb1767efd78/msgpack-1.0.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:52700dc63a4676669b341ba33520f4d6e43d3ca58d422e22ba66d1736b0a6e4c", size = 579836 }, + { url = "https://files.pythonhosted.org/packages/5d/4d/d98592099d4f18945f89cf3e634dc0cb128bb33b1b93f85a84173d35e181/msgpack-1.0.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e45ae4927759289c30ccba8d9fdce62bb414977ba158286b5ddaf8df2cddb5c5", size = 556587 }, + { url = "https://files.pythonhosted.org/packages/5e/44/6556ffe169bf2c0e974e2ea25fb82a7e55ebcf52a81b03a5e01820de5f84/msgpack-1.0.7-cp312-cp312-win32.whl", hash = "sha256:27dcd6f46a21c18fa5e5deed92a43d4554e3df8d8ca5a47bf0615d6a5f39dbc9", size = 216509 }, + { url = "https://files.pythonhosted.org/packages/dc/c1/63903f30d51d165e132e5221a2a4a1bbfab7508b68131c871d70bffac78a/msgpack-1.0.7-cp312-cp312-win_amd64.whl", hash = "sha256:7687e22a31e976a0e7fc99c2f4d11ca45eff652a81eb8c8085e9609298916dcf", size = 223287 }, +] + +[[package]] +name = "mypy" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/f8/65a7ce8d0e09b6329ad0c8d40330d100ea343bd4dd04c4f8ae26462d0a17/mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13", size = 10738433 }, + { url = "https://files.pythonhosted.org/packages/b4/95/9c0ecb8eacfe048583706249439ff52105b3f552ea9c4024166c03224270/mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559", size = 9861472 }, + { url = "https://files.pythonhosted.org/packages/84/09/9ec95e982e282e20c0d5407bc65031dfd0f0f8ecc66b69538296e06fcbee/mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b", size = 11611424 }, + { url = "https://files.pythonhosted.org/packages/78/13/f7d14e55865036a1e6a0a69580c240f43bc1f37407fe9235c0d4ef25ffb0/mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3", size = 12365450 }, + { url = "https://files.pythonhosted.org/packages/48/e1/301a73852d40c241e915ac6d7bcd7fedd47d519246db2d7b86b9d7e7a0cb/mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b", size = 12551765 }, + { url = "https://files.pythonhosted.org/packages/77/ba/c37bc323ae5fe7f3f15a28e06ab012cd0b7552886118943e90b15af31195/mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828", size = 9274701 }, + { url = "https://files.pythonhosted.org/packages/03/bc/f6339726c627bd7ca1ce0fa56c9ae2d0144604a319e0e339bdadafbbb599/mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f", size = 10662338 }, + { url = "https://files.pythonhosted.org/packages/e2/90/8dcf506ca1a09b0d17555cc00cd69aee402c203911410136cd716559efe7/mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5", size = 9787540 }, + { url = "https://files.pythonhosted.org/packages/05/05/a10f9479681e5da09ef2f9426f650d7b550d4bafbef683b69aad1ba87457/mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e", size = 11538051 }, + { url = "https://files.pythonhosted.org/packages/e9/9a/1f7d18b30edd57441a6411fcbc0c6869448d1a4bacbaee60656ac0fc29c8/mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c", size = 12286751 }, + { url = "https://files.pythonhosted.org/packages/72/af/19ff499b6f1dafcaf56f9881f7a965ac2f474f69f6f618b5175b044299f5/mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f", size = 12421783 }, + { url = "https://files.pythonhosted.org/packages/96/39/11b57431a1f686c1aed54bf794870efe0f6aeca11aca281a0bd87a5ad42c/mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f", size = 9265618 }, + { url = "https://files.pythonhosted.org/packages/98/3a/03c74331c5eb8bd025734e04c9840532226775c47a2c39b56a0c8d4f128d/mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd", size = 10793981 }, + { url = "https://files.pythonhosted.org/packages/f0/1a/41759b18f2cfd568848a37c89030aeb03534411eef981df621d8fad08a1d/mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f", size = 9749175 }, + { url = "https://files.pythonhosted.org/packages/12/7e/873481abf1ef112c582db832740f4c11b2bfa510e829d6da29b0ab8c3f9c/mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464", size = 11455675 }, + { url = "https://files.pythonhosted.org/packages/b3/d0/92ae4cde706923a2d3f2d6c39629134063ff64b9dedca9c1388363da072d/mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee", size = 12410020 }, + { url = "https://files.pythonhosted.org/packages/46/8b/df49974b337cce35f828ba6fda228152d6db45fed4c86ba56ffe442434fd/mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e", size = 12498582 }, + { url = "https://files.pythonhosted.org/packages/13/50/da5203fcf6c53044a0b699939f31075c45ae8a4cadf538a9069b165c1050/mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22", size = 9366614 }, + { url = "https://files.pythonhosted.org/packages/6a/9b/fd2e05d6ffff24d912f150b87db9e364fa8282045c875654ce7e32fffa66/mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", size = 10788592 }, + { url = "https://files.pythonhosted.org/packages/74/37/b246d711c28a03ead1fd906bbc7106659aed7c089d55fe40dd58db812628/mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", size = 9753611 }, + { url = "https://files.pythonhosted.org/packages/a6/ac/395808a92e10cfdac8003c3de9a2ab6dc7cde6c0d2a4df3df1b815ffd067/mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", size = 11438443 }, + { url = "https://files.pythonhosted.org/packages/d2/8b/801aa06445d2de3895f59e476f38f3f8d610ef5d6908245f07d002676cbf/mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", size = 12402541 }, + { url = "https://files.pythonhosted.org/packages/c7/67/5a4268782eb77344cc613a4cf23540928e41f018a9a1ec4c6882baf20ab8/mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", size = 12494348 }, + { url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648 }, + { url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/f0/8fbc882ca80cf077f1b246c0e3c3465f7f415439bdea6b899f6b19f61f70/networkx-3.2.1-py3-none-any.whl", hash = "sha256:f18c69adc97877c42332c170849c96cefa91881c99a7cb3e95b7c659ebdc1ec2", size = 1647772 }, +] + +[[package]] +name = "numpy" +version = "1.26.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/94/ace0fdea5241a27d13543ee117cbc65868e82213fb31a8eb7fe9ff23f313/numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0", size = 20631468 }, + { url = "https://files.pythonhosted.org/packages/20/f7/b24208eba89f9d1b58c1668bc6c8c4fd472b20c45573cb767f59d49fb0f6/numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a", size = 13966411 }, + { url = "https://files.pythonhosted.org/packages/fc/a5/4beee6488160798683eed5bdb7eead455892c3b4e1f78d79d8d3f3b084ac/numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4", size = 14219016 }, + { url = "https://files.pythonhosted.org/packages/4b/d7/ecf66c1cd12dc28b4040b15ab4d17b773b87fa9d29ca16125de01adb36cd/numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f", size = 18240889 }, + { url = "https://files.pythonhosted.org/packages/24/03/6f229fe3187546435c4f6f89f6d26c129d4f5bed40552899fcf1f0bf9e50/numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a", size = 13876746 }, + { url = "https://files.pythonhosted.org/packages/39/fe/39ada9b094f01f5a35486577c848fe274e374bbf8d8f472e1423a0bbd26d/numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2", size = 18078620 }, + { url = "https://files.pythonhosted.org/packages/d5/ef/6ad11d51197aad206a9ad2286dc1aac6a378059e06e8cf22cd08ed4f20dc/numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07", size = 5972659 }, + { url = "https://files.pythonhosted.org/packages/19/77/538f202862b9183f54108557bfda67e17603fc560c384559e769321c9d92/numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5", size = 15808905 }, + { url = "https://files.pythonhosted.org/packages/11/57/baae43d14fe163fa0e4c47f307b6b2511ab8d7d30177c491960504252053/numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71", size = 20630554 }, + { url = "https://files.pythonhosted.org/packages/1a/2e/151484f49fd03944c4a3ad9c418ed193cfd02724e138ac8a9505d056c582/numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef", size = 13997127 }, + { url = "https://files.pythonhosted.org/packages/79/ae/7e5b85136806f9dadf4878bf73cf223fe5c2636818ba3ab1c585d0403164/numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e", size = 14222994 }, + { url = "https://files.pythonhosted.org/packages/3a/d0/edc009c27b406c4f9cbc79274d6e46d634d139075492ad055e3d68445925/numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5", size = 18252005 }, + { url = "https://files.pythonhosted.org/packages/09/bf/2b1aaf8f525f2923ff6cfcf134ae5e750e279ac65ebf386c75a0cf6da06a/numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a", size = 13885297 }, + { url = "https://files.pythonhosted.org/packages/df/a0/4e0f14d847cfc2a633a1c8621d00724f3206cfeddeb66d35698c4e2cf3d2/numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a", size = 18093567 }, + { url = "https://files.pythonhosted.org/packages/d2/b7/a734c733286e10a7f1a8ad1ae8c90f2d33bf604a96548e0a4a3a6739b468/numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20", size = 5968812 }, + { url = "https://files.pythonhosted.org/packages/3f/6b/5610004206cf7f8e7ad91c5a85a8c71b2f2f8051a0c0c4d5916b76d6cbb2/numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2", size = 15811913 }, + { url = "https://files.pythonhosted.org/packages/95/12/8f2020a8e8b8383ac0177dc9570aad031a3beb12e38847f7129bacd96228/numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218", size = 20335901 }, + { url = "https://files.pythonhosted.org/packages/75/5b/ca6c8bd14007e5ca171c7c03102d17b4f4e0ceb53957e8c44343a9546dcc/numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b", size = 13685868 }, + { url = "https://files.pythonhosted.org/packages/79/f8/97f10e6755e2a7d027ca783f63044d5b1bc1ae7acb12afe6a9b4286eac17/numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b", size = 13925109 }, + { url = "https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed", size = 17950613 }, + { url = "https://files.pythonhosted.org/packages/4c/0c/9c603826b6465e82591e05ca230dfc13376da512b25ccd0894709b054ed0/numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a", size = 13572172 }, + { url = "https://files.pythonhosted.org/packages/76/8c/2ba3902e1a0fc1c74962ea9bb33a534bb05984ad7ff9515bf8d07527cadd/numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0", size = 17786643 }, + { url = "https://files.pythonhosted.org/packages/28/4a/46d9e65106879492374999e76eb85f87b15328e06bd1550668f79f7b18c6/numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110", size = 5677803 }, + { url = "https://files.pythonhosted.org/packages/16/2e/86f24451c2d530c88daf997cb8d6ac622c1d40d19f5a031ed68a4b73a374/numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", size = 15517754 }, +] + +[[package]] +name = "onnx" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fe/0978403c8d710ece2f34006367e78de80410743fe0e7680c8f33f2dab20d/onnx-1.16.0.tar.gz", hash = "sha256:237c6987c6c59d9f44b6136f5819af79574f8d96a760a1fa843bede11f3822f7", size = 12303017 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/0b/f4705e4a3fa6fd0de971302fdae17ad176b024eca8c24360f0e37c00f9df/onnx-1.16.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:9eadbdce25b19d6216f426d6d99b8bc877a65ed92cbef9707751c6669190ba4f", size = 16514483 }, + { url = "https://files.pythonhosted.org/packages/b8/1c/50310a559857951fc6e069cf5d89deebe34287997d1c5928bca435456f62/onnx-1.16.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:034ae21a2aaa2e9c14119a840d2926d213c27aad29e5e3edaa30145a745048e1", size = 15012939 }, + { url = "https://files.pythonhosted.org/packages/ef/6e/96be6692ebcd8da568084d753f386ce08efa1f99b216f346ee281edd6cc3/onnx-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec22a43d74eb1f2303373e2fbe7fbcaa45fb225f4eb146edfed1356ada7a9aea", size = 15791856 }, + { url = "https://files.pythonhosted.org/packages/49/5f/d8e1a24247f506a77cbe22341c72ca91bea3b468c5d6bca2047d885ea3c6/onnx-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:298f28a2b5ac09145fa958513d3d1e6b349ccf86a877dbdcccad57713fe360b3", size = 15922279 }, + { url = "https://files.pythonhosted.org/packages/cb/14/562e4ac22cdf41f4465e3b114ef1a9467d513eeff0b9c2285c2da5db6ed1/onnx-1.16.0-cp310-cp310-win32.whl", hash = "sha256:66300197b52beca08bc6262d43c103289c5d45fde43fb51922ed1eb83658cf0c", size = 14335703 }, + { url = "https://files.pythonhosted.org/packages/3b/e2/471ff83b3862967791d67f630000afce038756afbdf0665a3d767677c851/onnx-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:ae0029f5e47bf70a1a62e7f88c80bca4ef39b844a89910039184221775df5e43", size = 14435099 }, + { url = "https://files.pythonhosted.org/packages/a4/b8/7accf3f93eee498711f0b7f07f6e93906e031622473e85ce9cd3578f6a92/onnx-1.16.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:f51179d4af3372b4f3800c558d204b592c61e4b4a18b8f61e0eea7f46211221a", size = 16514376 }, + { url = "https://files.pythonhosted.org/packages/cc/24/a328236b594d5fea23f70a3a8139e730cb43334f0b24693831c47c9064f0/onnx-1.16.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:5202559070afec5144332db216c20f2fff8323cf7f6512b0ca11b215eacc5bf3", size = 15012839 }, + { url = "https://files.pythonhosted.org/packages/80/12/57187bab3f830a47fa65eafe4fbaef01dfdf5042cf82a41fa440fab68766/onnx-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77579e7c15b4df39d29465b216639a5f9b74026bdd9e4b6306cd19a32dcfe67c", size = 15791944 }, + { url = "https://files.pythonhosted.org/packages/df/48/63f68b65d041aedffab41eea930563ca52aab70dbaa7d4820501618c1a70/onnx-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e60ca76ac24b65c25860d0f2d2cdd96d6320d062a01dd8ce87c5743603789b8", size = 15922450 }, + { url = "https://files.pythonhosted.org/packages/08/1b/4bdf4534f5ff08973725ba5409f95bbf64e2789cd20be615880dae689973/onnx-1.16.0-cp311-cp311-win32.whl", hash = "sha256:81b4ee01bc554e8a2b11ac6439882508a5377a1c6b452acd69a1eebb83571117", size = 14335808 }, + { url = "https://files.pythonhosted.org/packages/aa/d0/0514d02d2e84e7bb48a105877eae4065e54d7dabb60d0b60214fe2677346/onnx-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:7449241e70b847b9c3eb8dae622df8c1b456d11032a9d7e26e0ee8a698d5bf86", size = 14434905 }, + { url = "https://files.pythonhosted.org/packages/42/87/577adadda30ee08041e81ef02a331ca9d1a8df93a2e4c4c53ec56fbbc2ac/onnx-1.16.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:03a627488b1a9975d95d6a55582af3e14c7f3bb87444725b999935ddd271d352", size = 16516304 }, + { url = "https://files.pythonhosted.org/packages/e3/1b/6e1ea37e081cc49a28f0e4d3830b4c8525081354cf9f5529c6c92268fc77/onnx-1.16.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c392faeabd9283ee344ccb4b067d1fea9dfc614fa1f0de7c47589efd79e15e78", size = 15016538 }, + { url = "https://files.pythonhosted.org/packages/6d/07/f8fefd5eb0984be42ef677f0b7db7527edc4529224a34a3c31f7b12ec80d/onnx-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0efeb46985de08f0efe758cb54ad3457e821a05c2eaf5ba2ccb8cd1602c08084", size = 15790415 }, + { url = "https://files.pythonhosted.org/packages/11/71/c219ce6d4b5205c77405af7f2de2511ad4eeffbfeb77a422151e893de0ea/onnx-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ddf14a3d32234f23e44abb73a755cb96a423fac7f004e8f046f36b10214151ee", size = 15922224 }, + { url = "https://files.pythonhosted.org/packages/8e/a4/554a6e5741b42406c5b1970d04685d7f2012019d4178408ed4b3ec953033/onnx-1.16.0-cp312-cp312-win32.whl", hash = "sha256:62a2e27ae8ba5fc9b4a2620301446a517b5ffaaf8566611de7a7c2160f5bcf4c", size = 14336234 }, + { url = "https://files.pythonhosted.org/packages/e9/a1/8aecec497010ad34e7656408df1868d94483c5c56bc991f4088c06150896/onnx-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:3e0860fea94efde777e81a6f68f65761ed5e5f3adea2e050d7fbe373a9ae05b3", size = 14436591 }, +] + +[[package]] +name = "onnxruntime" +version = "1.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coloredlogs" }, + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "sympy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/28/99f903b0eb1cd6f3faa0e343217d9fb9f47b84bca98bd9859884631336ee/onnxruntime-1.20.1-cp310-cp310-macosx_13_0_universal2.whl", hash = "sha256:e50ba5ff7fed4f7d9253a6baf801ca2883cc08491f9d32d78a80da57256a5439", size = 30996314 }, + { url = "https://files.pythonhosted.org/packages/6d/c6/c4c0860bee2fde6037bdd9dcd12d323f6e38cf00fcc9a5065b394337fc55/onnxruntime-1.20.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b2908b50101a19e99c4d4e97ebb9905561daf61829403061c1adc1b588bc0de", size = 11954010 }, + { url = "https://files.pythonhosted.org/packages/63/47/3dc0b075ab539f16b3d8b09df6b504f51836086ee709690a6278d791737d/onnxruntime-1.20.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d82daaec24045a2e87598b8ac2b417b1cce623244e80e663882e9fe1aae86410", size = 13330452 }, + { url = "https://files.pythonhosted.org/packages/27/ef/80fab86289ecc01a734b7ddf115dfb93d8b2e004bd1e1977e12881c72b12/onnxruntime-1.20.1-cp310-cp310-win32.whl", hash = "sha256:4c4b251a725a3b8cf2aab284f7d940c26094ecd9d442f07dd81ab5470e99b83f", size = 9813849 }, + { url = "https://files.pythonhosted.org/packages/a9/e6/33ab10066c9875a29d55e66ae97c3bf91b9b9b987179455d67c32261a49c/onnxruntime-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:d3b616bb53a77a9463707bb313637223380fc327f5064c9a782e8ec69c22e6a2", size = 11329702 }, + { url = "https://files.pythonhosted.org/packages/95/8d/2634e2959b34aa8a0037989f4229e9abcfa484e9c228f99633b3241768a6/onnxruntime-1.20.1-cp311-cp311-macosx_13_0_universal2.whl", hash = "sha256:06bfbf02ca9ab5f28946e0f912a562a5f005301d0c419283dc57b3ed7969bb7b", size = 30998725 }, + { url = "https://files.pythonhosted.org/packages/a5/da/c44bf9bd66cd6d9018a921f053f28d819445c4d84b4dd4777271b0fe52a2/onnxruntime-1.20.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6243e34d74423bdd1edf0ae9596dd61023b260f546ee17d701723915f06a9f7", size = 11955227 }, + { url = "https://files.pythonhosted.org/packages/11/ac/4120dfb74c8e45cce1c664fc7f7ce010edd587ba67ac41489f7432eb9381/onnxruntime-1.20.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5eec64c0269dcdb8d9a9a53dc4d64f87b9e0c19801d9321246a53b7eb5a7d1bc", size = 13331703 }, + { url = "https://files.pythonhosted.org/packages/12/f1/cefacac137f7bb7bfba57c50c478150fcd3c54aca72762ac2c05ce0532c1/onnxruntime-1.20.1-cp311-cp311-win32.whl", hash = "sha256:a19bc6e8c70e2485a1725b3d517a2319603acc14c1f1a017dda0afe6d4665b41", size = 9813977 }, + { url = "https://files.pythonhosted.org/packages/2c/2d/2d4d202c0bcfb3a4cc2b171abb9328672d7f91d7af9ea52572722c6d8d96/onnxruntime-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:8508887eb1c5f9537a4071768723ec7c30c28eb2518a00d0adcd32c89dea3221", size = 11329895 }, + { url = "https://files.pythonhosted.org/packages/e5/39/9335e0874f68f7d27103cbffc0e235e32e26759202df6085716375c078bb/onnxruntime-1.20.1-cp312-cp312-macosx_13_0_universal2.whl", hash = "sha256:22b0655e2bf4f2161d52706e31f517a0e54939dc393e92577df51808a7edc8c9", size = 31007580 }, + { url = "https://files.pythonhosted.org/packages/c5/9d/a42a84e10f1744dd27c6f2f9280cc3fb98f869dd19b7cd042e391ee2ab61/onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f56e898815963d6dc4ee1c35fc6c36506466eff6d16f3cb9848cea4e8c8172", size = 11952833 }, + { url = "https://files.pythonhosted.org/packages/47/42/2f71f5680834688a9c81becbe5c5bb996fd33eaed5c66ae0606c3b1d6a02/onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb71a814f66517a65628c9e4a2bb530a6edd2cd5d87ffa0af0f6f773a027d99e", size = 13333903 }, + { url = "https://files.pythonhosted.org/packages/c8/f1/aabfdf91d013320aa2fc46cf43c88ca0182860ff15df872b4552254a9680/onnxruntime-1.20.1-cp312-cp312-win32.whl", hash = "sha256:bd386cc9ee5f686ee8a75ba74037750aca55183085bf1941da8efcfe12d5b120", size = 9814562 }, + { url = "https://files.pythonhosted.org/packages/dd/80/76979e0b744307d488c79e41051117634b956612cc731f1028eb17ee7294/onnxruntime-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:19c2d843eb074f385e8bbb753a40df780511061a63f9def1b216bf53860223fb", size = 11331482 }, + { url = "https://files.pythonhosted.org/packages/f7/71/c5d980ac4189589267a06f758bd6c5667d07e55656bed6c6c0580733ad07/onnxruntime-1.20.1-cp313-cp313-macosx_13_0_universal2.whl", hash = "sha256:cc01437a32d0042b606f462245c8bbae269e5442797f6213e36ce61d5abdd8cc", size = 31007574 }, + { url = "https://files.pythonhosted.org/packages/81/0d/13bbd9489be2a6944f4a940084bfe388f1100472f38c07080a46fbd4ab96/onnxruntime-1.20.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb44b08e017a648924dbe91b82d89b0c105b1adcfe31e90d1dc06b8677ad37be", size = 11951459 }, + { url = "https://files.pythonhosted.org/packages/c0/ea/4454ae122874fd52bbb8a961262de81c5f932edeb1b72217f594c700d6ef/onnxruntime-1.20.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bda6aebdf7917c1d811f21d41633df00c58aff2bef2f598f69289c1f1dabc4b3", size = 13331620 }, + { url = "https://files.pythonhosted.org/packages/d8/e0/50db43188ca1c945decaa8fc2a024c33446d31afed40149897d4f9de505f/onnxruntime-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:d30367df7e70f1d9fc5a6a68106f5961686d39b54d3221f760085524e8d38e16", size = 11331758 }, + { url = "https://files.pythonhosted.org/packages/d8/55/3821c5fd60b52a6c82a00bba18531793c93c4addfe64fbf061e235c5617a/onnxruntime-1.20.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9158465745423b2b5d97ed25aa7740c7d38d2993ee2e5c3bfacb0c4145c49d8", size = 11950342 }, + { url = "https://files.pythonhosted.org/packages/14/56/fd990ca222cef4f9f4a9400567b9a15b220dee2eafffb16b2adbc55c8281/onnxruntime-1.20.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0df6f2df83d61f46e842dbcde610ede27218947c33e994545a22333491e72a3b", size = 13337040 }, +] + +[[package]] +name = "onnxruntime-gpu" +version = "1.19.2" +source = { registry = "https://aiinfra.pkgs.visualstudio.com/PublicPackages/_packaging/onnxruntime-cuda-12/pypi/simple/" } +dependencies = [ + { name = "coloredlogs" }, + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "sympy" }, +] +wheels = [ + { url = "https://aiinfra.pkgs.visualstudio.com/2692857e-05ef-43b4-ba9c-ccf1c22c437c/_packaging/9387c3aa-d9ad-4513-968c-383f6f7f53b8/pypi/download/onnxruntime-gpu/1.19.2/onnxruntime_gpu-1.19.2-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a49740e079e7c5215830d30cde3df792e903df007aa0b0fd7aa797937061b27a" }, + { url = "https://aiinfra.pkgs.visualstudio.com/2692857e-05ef-43b4-ba9c-ccf1c22c437c/_packaging/9387c3aa-d9ad-4513-968c-383f6f7f53b8/pypi/download/onnxruntime-gpu/1.19.2/onnxruntime_gpu-1.19.2-cp310-cp310-win_amd64.whl", hash = "sha256:b895920bb5e4241299f68874e0becdc2635ea0142939c11e7ff5ae5b28993613" }, + { url = "https://aiinfra.pkgs.visualstudio.com/2692857e-05ef-43b4-ba9c-ccf1c22c437c/_packaging/9387c3aa-d9ad-4513-968c-383f6f7f53b8/pypi/download/onnxruntime-gpu/1.19.2/onnxruntime_gpu-1.19.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:562fc7c755393eaad9751e56149339dd201ffbfdb3ef5f43ff21d0619ba9045f" }, + { url = "https://aiinfra.pkgs.visualstudio.com/2692857e-05ef-43b4-ba9c-ccf1c22c437c/_packaging/9387c3aa-d9ad-4513-968c-383f6f7f53b8/pypi/download/onnxruntime-gpu/1.19.2/onnxruntime_gpu-1.19.2-cp311-cp311-win_amd64.whl", hash = "sha256:522f7495918176cb8c1a3c78bde7152d984f7096acc786c73a27643af8af87c9" }, + { url = "https://aiinfra.pkgs.visualstudio.com/2692857e-05ef-43b4-ba9c-ccf1c22c437c/_packaging/9387c3aa-d9ad-4513-968c-383f6f7f53b8/pypi/download/onnxruntime-gpu/1.19.2/onnxruntime_gpu-1.19.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:554a02a3fac0119707eb87327908afd21c4e6f0fa5bf9a034398f098adc316c5" }, + { url = "https://aiinfra.pkgs.visualstudio.com/2692857e-05ef-43b4-ba9c-ccf1c22c437c/_packaging/9387c3aa-d9ad-4513-968c-383f6f7f53b8/pypi/download/onnxruntime-gpu/1.19.2/onnxruntime_gpu-1.19.2-cp312-cp312-win_amd64.whl", hash = "sha256:e7c6165a405027e3c0f11d189ae7013b5d66919b3381f9bfb3405c0c0cf07968" }, +] + +[[package]] +name = "onnxruntime-openvino" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coloredlogs" }, + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "sympy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/57/e9a080f2477b2a4c16925f766e4615fc545098b0f4e20cf8ad803e7a9672/onnxruntime_openvino-1.18.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:565b874d21bcd48126da7d62f57db019f5ec0e1f82ae9b0740afa2ad91f8d331", size = 41971800 }, + { url = "https://files.pythonhosted.org/packages/34/7d/b75913bce58f4ee9bf6a02d1b513b9fc82303a496ec698e6fb1f9d597cb4/onnxruntime_openvino-1.18.0-cp310-cp310-win_amd64.whl", hash = "sha256:7f1931060f710a6c8e32121bb73044c4772ef5925802fc8776d3fe1e87ab3f75", size = 5963263 }, + { url = "https://files.pythonhosted.org/packages/7e/d3/8299b7285dc8fa7bd986b6f0d7c50b7f0fd13db50dd3b88b93ec269b1e08/onnxruntime_openvino-1.18.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:eb1723d386f70a8e26398d983ebe35d2c25ba56e9cdb382670ebbf1f5139f8ba", size = 41971927 }, + { url = "https://files.pythonhosted.org/packages/88/d9/ca0bfd7ed37153d9664ccdcfb4d0e5b1963563553b05cb4338b46968feb2/onnxruntime_openvino-1.18.0-cp311-cp311-win_amd64.whl", hash = "sha256:874a1e263dd86674593e5a879257650b06a8609c4d5768c3d8ed8dc4ae874b9c", size = 5963464 }, +] + +[[package]] +name = "opencv-python-headless" +version = "4.11.0.86" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/2f/5b2b3ba52c864848885ba988f24b7f105052f68da9ab0e693cc7c25b0b30/opencv-python-headless-4.11.0.86.tar.gz", hash = "sha256:996eb282ca4b43ec6a3972414de0e2331f5d9cda2b41091a49739c19fb843798", size = 95177929 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/53/2c50afa0b1e05ecdb4603818e85f7d174e683d874ef63a6abe3ac92220c8/opencv_python_headless-4.11.0.86-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:48128188ade4a7e517237c8e1e11a9cdf5c282761473383e77beb875bb1e61ca", size = 37326460 }, + { url = "https://files.pythonhosted.org/packages/3b/43/68555327df94bb9b59a1fd645f63fafb0762515344d2046698762fc19d58/opencv_python_headless-4.11.0.86-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:a66c1b286a9de872c343ee7c3553b084244299714ebb50fbdcd76f07ebbe6c81", size = 56723330 }, + { url = "https://files.pythonhosted.org/packages/45/be/1438ce43ebe65317344a87e4b150865c5585f4c0db880a34cdae5ac46881/opencv_python_headless-4.11.0.86-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6efabcaa9df731f29e5ea9051776715b1bdd1845d7c9530065c7951d2a2899eb", size = 29487060 }, + { url = "https://files.pythonhosted.org/packages/dd/5c/c139a7876099916879609372bfa513b7f1257f7f1a908b0bdc1c2328241b/opencv_python_headless-4.11.0.86-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e0a27c19dd1f40ddff94976cfe43066fbbe9dfbb2ec1907d66c19caef42a57b", size = 49969856 }, + { url = "https://files.pythonhosted.org/packages/95/dd/ed1191c9dc91abcc9f752b499b7928aacabf10567bb2c2535944d848af18/opencv_python_headless-4.11.0.86-cp37-abi3-win32.whl", hash = "sha256:f447d8acbb0b6f2808da71fddd29c1cdd448d2bc98f72d9bb78a7a898fc9621b", size = 29324425 }, + { url = "https://files.pythonhosted.org/packages/86/8a/69176a64335aed183529207ba8bc3d329c2999d852b4f3818027203f50e6/opencv_python_headless-4.11.0.86-cp37-abi3-win_amd64.whl", hash = "sha256:6c304df9caa7a6a5710b91709dd4786bf20a74d57672b3c31f7033cc638174ca", size = 39402386 }, +] + +[[package]] +name = "orjson" +version = "3.10.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/5dea21763eeff8c1590076918a446ea3d6140743e0e36f58f369928ed0f4/orjson-3.10.15.tar.gz", hash = "sha256:05ca7fe452a2e9d8d9d706a2984c95b9c2ebc5db417ce0b7a49b91d50642a23e", size = 5282482 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/09/e5ff18ad009e6f97eb7edc5f67ef98b3ce0c189da9c3eaca1f9587cd4c61/orjson-3.10.15-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:552c883d03ad185f720d0c09583ebde257e41b9521b74ff40e08b7dec4559c04", size = 249532 }, + { url = "https://files.pythonhosted.org/packages/bd/b8/a75883301fe332bd433d9b0ded7d2bb706ccac679602c3516984f8814fb5/orjson-3.10.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e3e8d438d02e4854f70bfdc03a6bcdb697358dbaa6bcd19cbe24d24ece1f8", size = 125229 }, + { url = "https://files.pythonhosted.org/packages/83/4b/22f053e7a364cc9c685be203b1e40fc5f2b3f164a9b2284547504eec682e/orjson-3.10.15-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7c2c79fa308e6edb0ffab0a31fd75a7841bf2a79a20ef08a3c6e3b26814c8ca8", size = 150148 }, + { url = "https://files.pythonhosted.org/packages/63/64/1b54fc75ca328b57dd810541a4035fe48c12a161d466e3cf5b11a8c25649/orjson-3.10.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cb85490aa6bf98abd20607ab5c8324c0acb48d6da7863a51be48505646c814", size = 139748 }, + { url = "https://files.pythonhosted.org/packages/5e/ff/ff0c5da781807bb0a5acd789d9a7fbcb57f7b0c6e1916595da1f5ce69f3c/orjson-3.10.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:763dadac05e4e9d2bc14938a45a2d0560549561287d41c465d3c58aec818b164", size = 154559 }, + { url = "https://files.pythonhosted.org/packages/4e/9a/11e2974383384ace8495810d4a2ebef5f55aacfc97b333b65e789c9d362d/orjson-3.10.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a330b9b4734f09a623f74a7490db713695e13b67c959713b78369f26b3dee6bf", size = 130349 }, + { url = "https://files.pythonhosted.org/packages/2d/c4/dd9583aea6aefee1b64d3aed13f51d2aadb014028bc929fe52936ec5091f/orjson-3.10.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a61a4622b7ff861f019974f73d8165be1bd9a0855e1cad18ee167acacabeb061", size = 138514 }, + { url = "https://files.pythonhosted.org/packages/53/3e/dcf1729230654f5c5594fc752de1f43dcf67e055ac0d300c8cdb1309269a/orjson-3.10.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:acd271247691574416b3228db667b84775c497b245fa275c6ab90dc1ffbbd2b3", size = 130940 }, + { url = "https://files.pythonhosted.org/packages/e8/2b/b9759fe704789937705c8a56a03f6c03e50dff7df87d65cba9a20fec5282/orjson-3.10.15-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4759b109c37f635aa5c5cc93a1b26927bfde24b254bcc0e1149a9fada253d2d", size = 414713 }, + { url = "https://files.pythonhosted.org/packages/a7/6b/b9dfdbd4b6e20a59238319eb203ae07c3f6abf07eef909169b7a37ae3bba/orjson-3.10.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9e992fd5cfb8b9f00bfad2fd7a05a4299db2bbe92e6440d9dd2fab27655b3182", size = 141028 }, + { url = "https://files.pythonhosted.org/packages/7c/b5/40f5bbea619c7caf75eb4d652a9821875a8ed04acc45fe3d3ef054ca69fb/orjson-3.10.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f95fb363d79366af56c3f26b71df40b9a583b07bbaaf5b317407c4d58497852e", size = 129715 }, + { url = "https://files.pythonhosted.org/packages/38/60/2272514061cbdf4d672edbca6e59c7e01cd1c706e881427d88f3c3e79761/orjson-3.10.15-cp310-cp310-win32.whl", hash = "sha256:f9875f5fea7492da8ec2444839dcc439b0ef298978f311103d0b7dfd775898ab", size = 142473 }, + { url = "https://files.pythonhosted.org/packages/11/5d/be1490ff7eafe7fef890eb4527cf5bcd8cfd6117f3efe42a3249ec847b60/orjson-3.10.15-cp310-cp310-win_amd64.whl", hash = "sha256:17085a6aa91e1cd70ca8533989a18b5433e15d29c574582f76f821737c8d5806", size = 133564 }, + { url = "https://files.pythonhosted.org/packages/7a/a2/21b25ce4a2c71dbb90948ee81bd7a42b4fbfc63162e57faf83157d5540ae/orjson-3.10.15-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:c4cc83960ab79a4031f3119cc4b1a1c627a3dc09df125b27c4201dff2af7eaa6", size = 249533 }, + { url = "https://files.pythonhosted.org/packages/b2/85/2076fc12d8225698a51278009726750c9c65c846eda741e77e1761cfef33/orjson-3.10.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ddbeef2481d895ab8be5185f2432c334d6dec1f5d1933a9c83014d188e102cef", size = 125230 }, + { url = "https://files.pythonhosted.org/packages/06/df/a85a7955f11274191eccf559e8481b2be74a7c6d43075d0a9506aa80284d/orjson-3.10.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9e590a0477b23ecd5b0ac865b1b907b01b3c5535f5e8a8f6ab0e503efb896334", size = 150148 }, + { url = "https://files.pythonhosted.org/packages/37/b3/94c55625a29b8767c0eed194cb000b3787e3c23b4cdd13be17bae6ccbb4b/orjson-3.10.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a6be38bd103d2fd9bdfa31c2720b23b5d47c6796bcb1d1b598e3924441b4298d", size = 139749 }, + { url = "https://files.pythonhosted.org/packages/53/ba/c608b1e719971e8ddac2379f290404c2e914cf8e976369bae3cad88768b1/orjson-3.10.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ff4f6edb1578960ed628a3b998fa54d78d9bb3e2eb2cfc5c2a09732431c678d0", size = 154558 }, + { url = "https://files.pythonhosted.org/packages/b2/c4/c1fb835bb23ad788a39aa9ebb8821d51b1c03588d9a9e4ca7de5b354fdd5/orjson-3.10.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0482b21d0462eddd67e7fce10b89e0b6ac56570424662b685a0d6fccf581e13", size = 130349 }, + { url = "https://files.pythonhosted.org/packages/78/14/bb2b48b26ab3c570b284eb2157d98c1ef331a8397f6c8bd983b270467f5c/orjson-3.10.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bb5cc3527036ae3d98b65e37b7986a918955f85332c1ee07f9d3f82f3a6899b5", size = 138513 }, + { url = "https://files.pythonhosted.org/packages/4a/97/d5b353a5fe532e92c46467aa37e637f81af8468aa894cd77d2ec8a12f99e/orjson-3.10.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d569c1c462912acdd119ccbf719cf7102ea2c67dd03b99edcb1a3048651ac96b", size = 130942 }, + { url = "https://files.pythonhosted.org/packages/b5/5d/a067bec55293cca48fea8b9928cfa84c623be0cce8141d47690e64a6ca12/orjson-3.10.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1e6d33efab6b71d67f22bf2962895d3dc6f82a6273a965fab762e64fa90dc399", size = 414717 }, + { url = "https://files.pythonhosted.org/packages/6f/9a/1485b8b05c6b4c4db172c438cf5db5dcfd10e72a9bc23c151a1137e763e0/orjson-3.10.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c33be3795e299f565681d69852ac8c1bc5c84863c0b0030b2b3468843be90388", size = 141033 }, + { url = "https://files.pythonhosted.org/packages/f8/d2/fc67523656e43a0c7eaeae9007c8b02e86076b15d591e9be11554d3d3138/orjson-3.10.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:eea80037b9fae5339b214f59308ef0589fc06dc870578b7cce6d71eb2096764c", size = 129720 }, + { url = "https://files.pythonhosted.org/packages/79/42/f58c7bd4e5b54da2ce2ef0331a39ccbbaa7699b7f70206fbf06737c9ed7d/orjson-3.10.15-cp311-cp311-win32.whl", hash = "sha256:d5ac11b659fd798228a7adba3e37c010e0152b78b1982897020a8e019a94882e", size = 142473 }, + { url = "https://files.pythonhosted.org/packages/00/f8/bb60a4644287a544ec81df1699d5b965776bc9848d9029d9f9b3402ac8bb/orjson-3.10.15-cp311-cp311-win_amd64.whl", hash = "sha256:cf45e0214c593660339ef63e875f32ddd5aa3b4adc15e662cdb80dc49e194f8e", size = 133570 }, + { url = "https://files.pythonhosted.org/packages/66/85/22fe737188905a71afcc4bf7cc4c79cd7f5bbe9ed1fe0aac4ce4c33edc30/orjson-3.10.15-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9d11c0714fc85bfcf36ada1179400862da3288fc785c30e8297844c867d7505a", size = 249504 }, + { url = "https://files.pythonhosted.org/packages/48/b7/2622b29f3afebe938a0a9037e184660379797d5fd5234e5998345d7a5b43/orjson-3.10.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dba5a1e85d554e3897fa9fe6fbcff2ed32d55008973ec9a2b992bd9a65d2352d", size = 125080 }, + { url = "https://files.pythonhosted.org/packages/ce/8f/0b72a48f4403d0b88b2a41450c535b3e8989e8a2d7800659a967efc7c115/orjson-3.10.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7723ad949a0ea502df656948ddd8b392780a5beaa4c3b5f97e525191b102fff0", size = 150121 }, + { url = "https://files.pythonhosted.org/packages/06/ec/acb1a20cd49edb2000be5a0404cd43e3c8aad219f376ac8c60b870518c03/orjson-3.10.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6fd9bc64421e9fe9bd88039e7ce8e58d4fead67ca88e3a4014b143cec7684fd4", size = 139796 }, + { url = "https://files.pythonhosted.org/packages/33/e1/f7840a2ea852114b23a52a1c0b2bea0a1ea22236efbcdb876402d799c423/orjson-3.10.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dadba0e7b6594216c214ef7894c4bd5f08d7c0135f4dd0145600be4fbcc16767", size = 154636 }, + { url = "https://files.pythonhosted.org/packages/fa/da/31543337febd043b8fa80a3b67de627669b88c7b128d9ad4cc2ece005b7a/orjson-3.10.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b48f59114fe318f33bbaee8ebeda696d8ccc94c9e90bc27dbe72153094e26f41", size = 130621 }, + { url = "https://files.pythonhosted.org/packages/ed/78/66115dc9afbc22496530d2139f2f4455698be444c7c2475cb48f657cefc9/orjson-3.10.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:035fb83585e0f15e076759b6fedaf0abb460d1765b6a36f48018a52858443514", size = 138516 }, + { url = "https://files.pythonhosted.org/packages/22/84/cd4f5fb5427ffcf823140957a47503076184cb1ce15bcc1165125c26c46c/orjson-3.10.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d13b7fe322d75bf84464b075eafd8e7dd9eae05649aa2a5354cfa32f43c59f17", size = 130762 }, + { url = "https://files.pythonhosted.org/packages/93/1f/67596b711ba9f56dd75d73b60089c5c92057f1130bb3a25a0f53fb9a583b/orjson-3.10.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7066b74f9f259849629e0d04db6609db4cf5b973248f455ba5d3bd58a4daaa5b", size = 414700 }, + { url = "https://files.pythonhosted.org/packages/7c/0c/6a3b3271b46443d90efb713c3e4fe83fa8cd71cda0d11a0f69a03f437c6e/orjson-3.10.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:88dc3f65a026bd3175eb157fea994fca6ac7c4c8579fc5a86fc2114ad05705b7", size = 141077 }, + { url = "https://files.pythonhosted.org/packages/3b/9b/33c58e0bfc788995eccd0d525ecd6b84b40d7ed182dd0751cd4c1322ac62/orjson-3.10.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b342567e5465bd99faa559507fe45e33fc76b9fb868a63f1642c6bc0735ad02a", size = 129898 }, + { url = "https://files.pythonhosted.org/packages/01/c1/d577ecd2e9fa393366a1ea0a9267f6510d86e6c4bb1cdfb9877104cac44c/orjson-3.10.15-cp312-cp312-win32.whl", hash = "sha256:0a4f27ea5617828e6b58922fdbec67b0aa4bb844e2d363b9244c47fa2180e665", size = 142566 }, + { url = "https://files.pythonhosted.org/packages/ed/eb/a85317ee1732d1034b92d56f89f1de4d7bf7904f5c8fb9dcdd5b1c83917f/orjson-3.10.15-cp312-cp312-win_amd64.whl", hash = "sha256:ef5b87e7aa9545ddadd2309efe6824bd3dd64ac101c15dae0f2f597911d46eaa", size = 133732 }, + { url = "https://files.pythonhosted.org/packages/06/10/fe7d60b8da538e8d3d3721f08c1b7bff0491e8fa4dd3bf11a17e34f4730e/orjson-3.10.15-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bae0e6ec2b7ba6895198cd981b7cca95d1487d0147c8ed751e5632ad16f031a6", size = 249399 }, + { url = "https://files.pythonhosted.org/packages/6b/83/52c356fd3a61abd829ae7e4366a6fe8e8863c825a60d7ac5156067516edf/orjson-3.10.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f93ce145b2db1252dd86af37d4165b6faa83072b46e3995ecc95d4b2301b725a", size = 125044 }, + { url = "https://files.pythonhosted.org/packages/55/b2/d06d5901408e7ded1a74c7c20d70e3a127057a6d21355f50c90c0f337913/orjson-3.10.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7c203f6f969210128af3acae0ef9ea6aab9782939f45f6fe02d05958fe761ef9", size = 150066 }, + { url = "https://files.pythonhosted.org/packages/75/8c/60c3106e08dc593a861755781c7c675a566445cc39558677d505878d879f/orjson-3.10.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8918719572d662e18b8af66aef699d8c21072e54b6c82a3f8f6404c1f5ccd5e0", size = 139737 }, + { url = "https://files.pythonhosted.org/packages/6a/8c/ae00d7d0ab8a4490b1efeb01ad4ab2f1982e69cc82490bf8093407718ff5/orjson-3.10.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f71eae9651465dff70aa80db92586ad5b92df46a9373ee55252109bb6b703307", size = 154804 }, + { url = "https://files.pythonhosted.org/packages/22/86/65dc69bd88b6dd254535310e97bc518aa50a39ef9c5a2a5d518e7a223710/orjson-3.10.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e117eb299a35f2634e25ed120c37c641398826c2f5a3d3cc39f5993b96171b9e", size = 130583 }, + { url = "https://files.pythonhosted.org/packages/bb/00/6fe01ededb05d52be42fabb13d93a36e51f1fd9be173bd95707d11a8a860/orjson-3.10.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:13242f12d295e83c2955756a574ddd6741c81e5b99f2bef8ed8d53e47a01e4b7", size = 138465 }, + { url = "https://files.pythonhosted.org/packages/db/2f/4cc151c4b471b0cdc8cb29d3eadbce5007eb0475d26fa26ed123dca93b33/orjson-3.10.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7946922ada8f3e0b7b958cc3eb22cfcf6c0df83d1fe5521b4a100103e3fa84c8", size = 130742 }, + { url = "https://files.pythonhosted.org/packages/9f/13/8a6109e4b477c518498ca37963d9c0eb1508b259725553fb53d53b20e2ea/orjson-3.10.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b7155eb1623347f0f22c38c9abdd738b287e39b9982e1da227503387b81b34ca", size = 414669 }, + { url = "https://files.pythonhosted.org/packages/22/7b/1d229d6d24644ed4d0a803de1b0e2df832032d5beda7346831c78191b5b2/orjson-3.10.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:208beedfa807c922da4e81061dafa9c8489c6328934ca2a562efa707e049e561", size = 141043 }, + { url = "https://files.pythonhosted.org/packages/cc/d3/6dc91156cf12ed86bed383bcb942d84d23304a1e57b7ab030bf60ea130d6/orjson-3.10.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eca81f83b1b8c07449e1d6ff7074e82e3fd6777e588f1a6632127f286a968825", size = 129826 }, + { url = "https://files.pythonhosted.org/packages/b3/38/c47c25b86f6996f1343be721b6ea4367bc1c8bc0fc3f6bbcd995d18cb19d/orjson-3.10.15-cp313-cp313-win32.whl", hash = "sha256:c03cd6eea1bd3b949d0d007c8d57049aa2b39bd49f58b4b2af571a5d3833d890", size = 142542 }, + { url = "https://files.pythonhosted.org/packages/27/f1/1d7ec15b20f8ce9300bc850de1e059132b88990e46cd0ccac29cbf11e4f9/orjson-3.10.15-cp313-cp313-win_amd64.whl", hash = "sha256:fd56a26a04f6ba5fb2045b0acc487a63162a958ed837648c5781e1fe3316cfbf", size = 133444 }, +] + +[[package]] +name = "packaging" +version = "23.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/2b/9b9c33ffed44ee921d0967086d653047286054117d584f1b1a7c22ceaf7b/packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", size = 146714 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/1a/610693ac4ee14fcdf2d9bf3c493370e4f2ef7ae2e19217d7a237ff42367d/packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7", size = 53011 }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, +] + +[[package]] +name = "pillow" +version = "10.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/74/ad3d526f3bf7b6d3f408b73fde271ec69dfac8b81341a318ce825f2b3812/pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06", size = 46555059 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/69/a31cccd538ca0b5272be2a38347f8839b97a14be104ea08b0db92f749c74/pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e", size = 3509271 }, + { url = "https://files.pythonhosted.org/packages/9a/9e/4143b907be8ea0bce215f2ae4f7480027473f8b61fcedfda9d851082a5d2/pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d", size = 3375658 }, + { url = "https://files.pythonhosted.org/packages/8a/25/1fc45761955f9359b1169aa75e241551e74ac01a09f487adaaf4c3472d11/pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856", size = 4332075 }, + { url = "https://files.pythonhosted.org/packages/5e/dd/425b95d0151e1d6c951f45051112394f130df3da67363b6bc75dc4c27aba/pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f", size = 4444808 }, + { url = "https://files.pythonhosted.org/packages/b1/84/9a15cc5726cbbfe7f9f90bfb11f5d028586595907cd093815ca6644932e3/pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b", size = 4356290 }, + { url = "https://files.pythonhosted.org/packages/b5/5b/6651c288b08df3b8c1e2f8c1152201e0b25d240e22ddade0f1e242fc9fa0/pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc", size = 4525163 }, + { url = "https://files.pythonhosted.org/packages/07/8b/34854bf11a83c248505c8cb0fcf8d3d0b459a2246c8809b967963b6b12ae/pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e", size = 4463100 }, + { url = "https://files.pythonhosted.org/packages/78/63/0632aee4e82476d9cbe5200c0cdf9ba41ee04ed77887432845264d81116d/pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46", size = 4592880 }, + { url = "https://files.pythonhosted.org/packages/df/56/b8663d7520671b4398b9d97e1ed9f583d4afcbefbda3c6188325e8c297bd/pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984", size = 2235218 }, + { url = "https://files.pythonhosted.org/packages/f4/72/0203e94a91ddb4a9d5238434ae6c1ca10e610e8487036132ea9bf806ca2a/pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141", size = 2554487 }, + { url = "https://files.pythonhosted.org/packages/bd/52/7e7e93d7a6e4290543f17dc6f7d3af4bd0b3dd9926e2e8a35ac2282bc5f4/pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1", size = 2243219 }, + { url = "https://files.pythonhosted.org/packages/a7/62/c9449f9c3043c37f73e7487ec4ef0c03eb9c9afc91a92b977a67b3c0bbc5/pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c", size = 3509265 }, + { url = "https://files.pythonhosted.org/packages/f4/5f/491dafc7bbf5a3cc1845dc0430872e8096eb9e2b6f8161509d124594ec2d/pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be", size = 3375655 }, + { url = "https://files.pythonhosted.org/packages/73/d5/c4011a76f4207a3c151134cd22a1415741e42fa5ddecec7c0182887deb3d/pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3", size = 4340304 }, + { url = "https://files.pythonhosted.org/packages/ac/10/c67e20445a707f7a610699bba4fe050583b688d8cd2d202572b257f46600/pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6", size = 4452804 }, + { url = "https://files.pythonhosted.org/packages/a9/83/6523837906d1da2b269dee787e31df3b0acb12e3d08f024965a3e7f64665/pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe", size = 4365126 }, + { url = "https://files.pythonhosted.org/packages/ba/e5/8c68ff608a4203085158cff5cc2a3c534ec384536d9438c405ed6370d080/pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319", size = 4533541 }, + { url = "https://files.pythonhosted.org/packages/f4/7c/01b8dbdca5bc6785573f4cee96e2358b0918b7b2c7b60d8b6f3abf87a070/pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d", size = 4471616 }, + { url = "https://files.pythonhosted.org/packages/c8/57/2899b82394a35a0fbfd352e290945440e3b3785655a03365c0ca8279f351/pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696", size = 4600802 }, + { url = "https://files.pythonhosted.org/packages/4d/d7/a44f193d4c26e58ee5d2d9db3d4854b2cfb5b5e08d360a5e03fe987c0086/pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496", size = 2235213 }, + { url = "https://files.pythonhosted.org/packages/c1/d0/5866318eec2b801cdb8c82abf190c8343d8a1cd8bf5a0c17444a6f268291/pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91", size = 2554498 }, + { url = "https://files.pythonhosted.org/packages/d4/c8/310ac16ac2b97e902d9eb438688de0d961660a87703ad1561fd3dfbd2aa0/pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22", size = 2243219 }, + { url = "https://files.pythonhosted.org/packages/05/cb/0353013dc30c02a8be34eb91d25e4e4cf594b59e5a55ea1128fde1e5f8ea/pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94", size = 3509350 }, + { url = "https://files.pythonhosted.org/packages/e7/cf/5c558a0f247e0bf9cec92bff9b46ae6474dd736f6d906315e60e4075f737/pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597", size = 3374980 }, + { url = "https://files.pythonhosted.org/packages/84/48/6e394b86369a4eb68b8a1382c78dc092245af517385c086c5094e3b34428/pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80", size = 4343799 }, + { url = "https://files.pythonhosted.org/packages/3b/f3/a8c6c11fa84b59b9df0cd5694492da8c039a24cd159f0f6918690105c3be/pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca", size = 4459973 }, + { url = "https://files.pythonhosted.org/packages/7d/1b/c14b4197b80150fb64453585247e6fb2e1d93761fa0fa9cf63b102fde822/pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef", size = 4370054 }, + { url = "https://files.pythonhosted.org/packages/55/77/40daddf677897a923d5d33329acd52a2144d54a9644f2a5422c028c6bf2d/pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a", size = 4539484 }, + { url = "https://files.pythonhosted.org/packages/40/54/90de3e4256b1207300fb2b1d7168dd912a2fb4b2401e439ba23c2b2cabde/pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b", size = 4477375 }, + { url = "https://files.pythonhosted.org/packages/13/24/1bfba52f44193860918ff7c93d03d95e3f8748ca1de3ceaf11157a14cf16/pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9", size = 4608773 }, + { url = "https://files.pythonhosted.org/packages/55/04/5e6de6e6120451ec0c24516c41dbaf80cce1b6451f96561235ef2429da2e/pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42", size = 2235690 }, + { url = "https://files.pythonhosted.org/packages/74/0a/d4ce3c44bca8635bd29a2eab5aa181b654a734a29b263ca8efe013beea98/pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a", size = 2554951 }, + { url = "https://files.pythonhosted.org/packages/b5/ca/184349ee40f2e92439be9b3502ae6cfc43ac4b50bc4fc6b3de7957563894/pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9", size = 2243427 }, + { url = "https://files.pythonhosted.org/packages/c3/00/706cebe7c2c12a6318aabe5d354836f54adff7156fd9e1bd6c89f4ba0e98/pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3", size = 3525685 }, + { url = "https://files.pythonhosted.org/packages/cf/76/f658cbfa49405e5ecbfb9ba42d07074ad9792031267e782d409fd8fe7c69/pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb", size = 3374883 }, + { url = "https://files.pythonhosted.org/packages/46/2b/99c28c4379a85e65378211971c0b430d9c7234b1ec4d59b2668f6299e011/pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70", size = 4339837 }, + { url = "https://files.pythonhosted.org/packages/f1/74/b1ec314f624c0c43711fdf0d8076f82d9d802afd58f1d62c2a86878e8615/pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be", size = 4455562 }, + { url = "https://files.pythonhosted.org/packages/4a/2a/4b04157cb7b9c74372fa867096a1607e6fedad93a44deeff553ccd307868/pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0", size = 4366761 }, + { url = "https://files.pythonhosted.org/packages/ac/7b/8f1d815c1a6a268fe90481232c98dd0e5fa8c75e341a75f060037bd5ceae/pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc", size = 4536767 }, + { url = "https://files.pythonhosted.org/packages/e5/77/05fa64d1f45d12c22c314e7b97398ffb28ef2813a485465017b7978b3ce7/pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a", size = 4477989 }, + { url = "https://files.pythonhosted.org/packages/12/63/b0397cfc2caae05c3fb2f4ed1b4fc4fc878f0243510a7a6034ca59726494/pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309", size = 4610255 }, + { url = "https://files.pythonhosted.org/packages/7b/f9/cfaa5082ca9bc4a6de66ffe1c12c2d90bf09c309a5f52b27759a596900e7/pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060", size = 2235603 }, + { url = "https://files.pythonhosted.org/packages/01/6a/30ff0eef6e0c0e71e55ded56a38d4859bf9d3634a94a88743897b5f96936/pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea", size = 2554972 }, + { url = "https://files.pythonhosted.org/packages/48/2c/2e0a52890f269435eee38b21c8218e102c621fe8d8df8b9dd06fabf879ba/pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d", size = 2243375 }, + { url = "https://files.pythonhosted.org/packages/38/30/095d4f55f3a053392f75e2eae45eba3228452783bab3d9a920b951ac495c/pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4", size = 3493889 }, + { url = "https://files.pythonhosted.org/packages/f3/e8/4ff79788803a5fcd5dc35efdc9386af153569853767bff74540725b45863/pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da", size = 3346160 }, + { url = "https://files.pythonhosted.org/packages/d7/ac/4184edd511b14f760c73f5bb8a5d6fd85c591c8aff7c2229677a355c4179/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026", size = 3435020 }, + { url = "https://files.pythonhosted.org/packages/da/21/1749cd09160149c0a246a81d646e05f35041619ce76f6493d6a96e8d1103/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e", size = 3490539 }, + { url = "https://files.pythonhosted.org/packages/b6/f5/f71fe1888b96083b3f6dfa0709101f61fc9e972c0c8d04e9d93ccef2a045/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5", size = 3476125 }, + { url = "https://files.pythonhosted.org/packages/96/b9/c0362c54290a31866c3526848583a2f45a535aa9d725fd31e25d318c805f/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885", size = 3579373 }, + { url = "https://files.pythonhosted.org/packages/52/3b/ce7a01026a7cf46e5452afa86f97a5e88ca97f562cafa76570178ab56d8d/pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5", size = 2554661 }, +] + +[[package]] +name = "platformdirs" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/62/d1/7feaaacb1a3faeba96c06e6c5091f90695cc0f94b7e8e1a3a3fe2b33ff9a/platformdirs-4.1.0.tar.gz", hash = "sha256:906d548203468492d432bcb294d4bc2fff751bf84971fbb2c10918cc206ee420", size = 19760 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/53/42fe5eab4a09d251a76d0043e018172db324a23fcdac70f77a551c11f618/platformdirs-4.1.0-py3-none-any.whl", hash = "sha256:11c8f37bcca40db96d8144522d925583bdb7a31f7b0e37e3ed4318400a8e2380", size = 17420 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/81/316b6a55a0d1f327d04cc7b0ba9d04058cb62de6c3a4d4b0df280cbe3b0b/prettytable-3.9.0-py3-none-any.whl", hash = "sha256:a71292ab7769a5de274b146b276ce938786f56c31cf7cea88b6f3775d82fe8c8", size = 27772 }, +] + +[[package]] +name = "protobuf" +version = "4.25.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/a5/05ea470f4e793c9408bc975ce1c6957447e3134ce7f7a58c13be8b2c216f/protobuf-4.25.2.tar.gz", hash = "sha256:fe599e175cb347efc8ee524bcd4b902d11f7262c0e569ececcb89995c15f0a5e", size = 380282 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/2f/01f63896ddf22cbb0173ab51f54fde70b0208ca6c2f5e8416950977930e1/protobuf-4.25.2-cp310-abi3-win32.whl", hash = "sha256:b50c949608682b12efb0b2717f53256f03636af5f60ac0c1d900df6213910fd6", size = 392408 }, + { url = "https://files.pythonhosted.org/packages/c1/00/c3ae19cabb36cfabc94ff0b102aac21b471c9f91a1357f8aafffb9efe8e0/protobuf-4.25.2-cp310-abi3-win_amd64.whl", hash = "sha256:8f62574857ee1de9f770baf04dde4165e30b15ad97ba03ceac65f760ff018ac9", size = 413397 }, + { url = "https://files.pythonhosted.org/packages/b3/81/0017aefacf23273d4efd1154ef958a27eed9c177c4cc09d2d4ba398fb47f/protobuf-4.25.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:2db9f8fa64fbdcdc93767d3cf81e0f2aef176284071507e3ede160811502fd3d", size = 394159 }, + { url = "https://files.pythonhosted.org/packages/23/17/405ba44f60a693dfe96c7a18e843707cffa0fcfad80bd8fc4f227f499ea5/protobuf-4.25.2-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:10894a2885b7175d3984f2be8d9850712c57d5e7587a2410720af8be56cdaf62", size = 293698 }, + { url = "https://files.pythonhosted.org/packages/81/9e/63501b8d5b4e40c7260049836bd15ec3270c936e83bc57b85e4603cc212c/protobuf-4.25.2-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:fc381d1dd0516343f1440019cedf08a7405f791cd49eef4ae1ea06520bc1c020", size = 294609 }, + { url = "https://files.pythonhosted.org/packages/ff/52/5d23df1fe3b368133ec3e2436fb3dd4ccedf44c8d5ac7f4a88087c75180b/protobuf-4.25.2-py3-none-any.whl", hash = "sha256:a8b7a98d4ce823303145bf3c1a8bdb0f2f4642a414b196f04ad9853ed0c8f830", size = 156463 }, +] + +[[package]] +name = "psutil" +version = "5.9.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/d0/c9ae661a302931735237791f04cb7086ac244377f78692ba3b3eae3a9619/psutil-5.9.7.tar.gz", hash = "sha256:3f02134e82cfb5d089fddf20bb2e03fd5cd52395321d1c8458a9e58500ff417c", size = 498429 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/63/86a4ccc640b4ee1193800f57bbd20b766853c0cdbdbb248a27cdfafe6cbf/psutil-5.9.7-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ea36cc62e69a13ec52b2f625c27527f6e4479bca2b340b7a452af55b34fcbe2e", size = 245972 }, + { url = "https://files.pythonhosted.org/packages/58/80/cc6666b3968646f2d94de66bbc63d701d501f4aa04de43dd7d1f5dc477dd/psutil-5.9.7-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1132704b876e58d277168cd729d64750633d5ff0183acf5b3c986b8466cd0284", size = 282514 }, + { url = "https://files.pythonhosted.org/packages/be/fa/f1f626620e3b47e6237dcc64cb8cc1472f139e99422e5b9fa5bbcf457f48/psutil-5.9.7-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe8b7f07948f1304497ce4f4684881250cd859b16d06a1dc4d7941eeb6233bfe", size = 285469 }, + { url = "https://files.pythonhosted.org/packages/7c/b8/dc6ebfc030b47cccc5f5229eeb15e64142b4782796c3ce169ccd60b4d511/psutil-5.9.7-cp37-abi3-win32.whl", hash = "sha256:c727ca5a9b2dd5193b8644b9f0c883d54f1248310023b5ad3e92036c5e2ada68", size = 248406 }, + { url = "https://files.pythonhosted.org/packages/50/28/92b74d95dd991c837813ffac0c79a581a3d129eb0fa7c1dd616d9901e0f3/psutil-5.9.7-cp37-abi3-win_amd64.whl", hash = "sha256:f37f87e4d73b79e6c5e749440c3113b81d1ee7d26f21c19c47371ddea834f414", size = 252245 }, + { url = "https://files.pythonhosted.org/packages/ba/8a/000d0e80156f0b96c55bda6c60f5ed6543d7b5e893ccab83117e50de1400/psutil-5.9.7-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:032f4f2c909818c86cea4fe2cc407f1c0f0cde8e6c6d702b28b8ce0c0d143340", size = 246739 }, +] + +[[package]] +name = "pycparser" +version = "2.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/0b/95d387f5f4433cb0f53ff7ad859bd2c6051051cebbb564f139a999ab46de/pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206", size = 170877 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/d5/5f610ebe421e85889f2e55e33b7f9a6795bd982198517d912eb1c76e1a53/pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", size = 118697 }, +] + +[[package]] +name = "pydantic" +version = "2.10.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 }, +] + +[[package]] +name = "pydantic-core" +version = "2.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/bc/fed5f74b5d802cf9a03e83f60f18864e90e3aed7223adaca5ffb7a8d8d64/pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa", size = 1895938 }, + { url = "https://files.pythonhosted.org/packages/71/2a/185aff24ce844e39abb8dd680f4e959f0006944f4a8a0ea372d9f9ae2e53/pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c", size = 1815684 }, + { url = "https://files.pythonhosted.org/packages/c3/43/fafabd3d94d159d4f1ed62e383e264f146a17dd4d48453319fd782e7979e/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a", size = 1829169 }, + { url = "https://files.pythonhosted.org/packages/a2/d1/f2dfe1a2a637ce6800b799aa086d079998959f6f1215eb4497966efd2274/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5", size = 1867227 }, + { url = "https://files.pythonhosted.org/packages/7d/39/e06fcbcc1c785daa3160ccf6c1c38fea31f5754b756e34b65f74e99780b5/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c", size = 2037695 }, + { url = "https://files.pythonhosted.org/packages/7a/67/61291ee98e07f0650eb756d44998214231f50751ba7e13f4f325d95249ab/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7", size = 2741662 }, + { url = "https://files.pythonhosted.org/packages/32/90/3b15e31b88ca39e9e626630b4c4a1f5a0dfd09076366f4219429e6786076/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a", size = 1993370 }, + { url = "https://files.pythonhosted.org/packages/ff/83/c06d333ee3a67e2e13e07794995c1535565132940715931c1c43bfc85b11/pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236", size = 1996813 }, + { url = "https://files.pythonhosted.org/packages/7c/f7/89be1c8deb6e22618a74f0ca0d933fdcb8baa254753b26b25ad3acff8f74/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962", size = 2005287 }, + { url = "https://files.pythonhosted.org/packages/b7/7d/8eb3e23206c00ef7feee17b83a4ffa0a623eb1a9d382e56e4aa46fd15ff2/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9", size = 2128414 }, + { url = "https://files.pythonhosted.org/packages/4e/99/fe80f3ff8dd71a3ea15763878d464476e6cb0a2db95ff1c5c554133b6b83/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af", size = 2155301 }, + { url = "https://files.pythonhosted.org/packages/2b/a3/e50460b9a5789ca1451b70d4f52546fa9e2b420ba3bfa6100105c0559238/pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4", size = 1816685 }, + { url = "https://files.pythonhosted.org/packages/57/4c/a8838731cb0f2c2a39d3535376466de6049034d7b239c0202a64aaa05533/pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31", size = 1982876 }, + { url = "https://files.pythonhosted.org/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421 }, + { url = "https://files.pythonhosted.org/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998 }, + { url = "https://files.pythonhosted.org/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167 }, + { url = "https://files.pythonhosted.org/packages/2b/5b/1b29e8c1fb5f3199a9a57c1452004ff39f494bbe9bdbe9a81e18172e40d3/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", size = 1865071 }, + { url = "https://files.pythonhosted.org/packages/89/6c/3985203863d76bb7d7266e36970d7e3b6385148c18a68cc8915fd8c84d57/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", size = 2036244 }, + { url = "https://files.pythonhosted.org/packages/0e/41/f15316858a246b5d723f7d7f599f79e37493b2e84bfc789e58d88c209f8a/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", size = 2737470 }, + { url = "https://files.pythonhosted.org/packages/a8/7c/b860618c25678bbd6d1d99dbdfdf0510ccb50790099b963ff78a124b754f/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", size = 1992291 }, + { url = "https://files.pythonhosted.org/packages/bf/73/42c3742a391eccbeab39f15213ecda3104ae8682ba3c0c28069fbcb8c10d/pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", size = 1994613 }, + { url = "https://files.pythonhosted.org/packages/94/7a/941e89096d1175d56f59340f3a8ebaf20762fef222c298ea96d36a6328c5/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", size = 2002355 }, + { url = "https://files.pythonhosted.org/packages/6e/95/2359937a73d49e336a5a19848713555605d4d8d6940c3ec6c6c0ca4dcf25/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", size = 2126661 }, + { url = "https://files.pythonhosted.org/packages/2b/4c/ca02b7bdb6012a1adef21a50625b14f43ed4d11f1fc237f9d7490aa5078c/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", size = 2153261 }, + { url = "https://files.pythonhosted.org/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361 }, + { url = "https://files.pythonhosted.org/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484 }, + { url = "https://files.pythonhosted.org/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102 }, + { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, + { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, + { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, + { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 }, + { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 }, + { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 }, + { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 }, + { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 }, + { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 }, + { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 }, + { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 }, + { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 }, + { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 }, + { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 }, + { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 }, + { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 }, + { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 }, + { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 }, + { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 }, + { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 }, + { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 }, + { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 }, + { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 }, + { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 }, + { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 }, + { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, + { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, + { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, + { url = "https://files.pythonhosted.org/packages/46/72/af70981a341500419e67d5cb45abe552a7c74b66326ac8877588488da1ac/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e", size = 1891159 }, + { url = "https://files.pythonhosted.org/packages/ad/3d/c5913cccdef93e0a6a95c2d057d2c2cba347815c845cda79ddd3c0f5e17d/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8", size = 1768331 }, + { url = "https://files.pythonhosted.org/packages/f6/f0/a3ae8fbee269e4934f14e2e0e00928f9346c5943174f2811193113e58252/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3", size = 1822467 }, + { url = "https://files.pythonhosted.org/packages/d7/7a/7bbf241a04e9f9ea24cd5874354a83526d639b02674648af3f350554276c/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f", size = 1979797 }, + { url = "https://files.pythonhosted.org/packages/4f/5f/4784c6107731f89e0005a92ecb8a2efeafdb55eb992b8e9d0a2be5199335/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133", size = 1987839 }, + { url = "https://files.pythonhosted.org/packages/6d/a7/61246562b651dff00de86a5f01b6e4befb518df314c54dec187a78d81c84/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc", size = 1998861 }, + { url = "https://files.pythonhosted.org/packages/86/aa/837821ecf0c022bbb74ca132e117c358321e72e7f9702d1b6a03758545e2/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50", size = 2116582 }, + { url = "https://files.pythonhosted.org/packages/81/b0/5e74656e95623cbaa0a6278d16cf15e10a51f6002e3ec126541e95c29ea3/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9", size = 2151985 }, + { url = "https://files.pythonhosted.org/packages/63/37/3e32eeb2a451fddaa3898e2163746b0cffbbdbb4740d38372db0490d67f3/pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151", size = 2004715 }, +] + +[[package]] +name = "pydantic-settings" +version = "2.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839 }, +] + +[[package]] +name = "pygments" +version = "2.17.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/55/59/8bccf4157baf25e4aa5a0bb7fa3ba8600907de105ebc22b0c78cfbf6f565/pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367", size = 4827772 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/9c/372fef8377a6e340b1704768d20daaded98bf13282b5327beb2e2fe2c7ef/pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c", size = 1179756 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/92/8486ede85fcc088f1b3dba4ce92dd29d126fd96b0008ea213167940a2475/pyparsing-3.1.1-py3-none-any.whl", hash = "sha256:32c7c0b711493c72ff18a981d24f28aaf9c1fb7ed5e9667c9e84e3db623bdbfb", size = 103139 }, +] + +[[package]] +name = "pyreadline3" +version = "3.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/86/3d61a61f36a0067874a00cb4dceb9028d34b6060e47828f7fc86fb9f7ee9/pyreadline3-3.4.1.tar.gz", hash = "sha256:6f3d1f7b8a31ba32b73917cefc1f28cc660562f39aea8646d30bd6eff21f7bae", size = 86465 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/fc/a3c13ded7b3057680c8ae95a9b6cc83e63657c38e0005c400a5d018a33a7/pyreadline3-3.4.1-py3-none-any.whl", hash = "sha256:b0efb6516fd4fb07b45949053826a62fa4cb353db5be2bbb4a7aa1fdd1e345fb", size = 95203 }, +] + +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, +] + +[[package]] +name = "pytest-asyncio" +version = "0.25.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/a8/ecbc8ede70921dd2f544ab1cadd3ff3bf842af27f87bbdea774c7baa1d38/pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a", size = 54239 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/17/3493c5624e48fd97156ebaec380dcaafee9506d7e2c46218ceebbb57d7de/pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3", size = 19467 }, +] + +[[package]] +name = "pytest-cov" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949 }, +] + +[[package]] +name = "pytest-mock" +version = "3.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/90/a955c3ab35ccd41ad4de556596fa86685bf4fc5ffcc62d22d856cfd4e29a/pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0", size = 32814 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/3b/b26f90f74e2986a82df6e7ac7e319b8ea7ccece1caec9f8ab6104dc70603/pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", size = 9863 }, +] + +[[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 } +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 }, +] + +[[package]] +name = "python-dotenv" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/06/1ef763af20d0572c032fa22882cfbfb005fba6e7300715a37840858c919e/python-dotenv-1.0.0.tar.gz", hash = "sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba", size = 37399 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/2f/62ea1c8b593f4e093cc1a7768f0d46112107e790c3e478532329e434f00b/python_dotenv-1.0.0-py3-none-any.whl", hash = "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a", size = 19482 }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546 }, +] + +[[package]] +name = "pywin32" +version = "306" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/dc/28c668097edfaf4eac4617ef7adf081b9cf50d254672fcf399a70f5efc41/pywin32-306-cp310-cp310-win32.whl", hash = "sha256:06d3420a5155ba65f0b72f2699b5bacf3109f36acbe8923765c22938a69dfc8d", size = 8506422 }, + { url = "https://files.pythonhosted.org/packages/d3/d6/891894edec688e72c2e308b3243fad98b4066e1839fd2fe78f04129a9d31/pywin32-306-cp310-cp310-win_amd64.whl", hash = "sha256:84f4471dbca1887ea3803d8848a1616429ac94a4a8d05f4bc9c5dcfd42ca99c8", size = 9226392 }, + { url = "https://files.pythonhosted.org/packages/8b/1e/fc18ad83ca553e01b97aa8393ff10e33c1fb57801db05488b83282ee9913/pywin32-306-cp311-cp311-win32.whl", hash = "sha256:e65028133d15b64d2ed8f06dd9fbc268352478d4f9289e69c190ecd6818b6407", size = 8507689 }, + { url = "https://files.pythonhosted.org/packages/7e/9e/ad6b1ae2a5ad1066dc509350e0fbf74d8d50251a51e420a2a8feaa0cecbd/pywin32-306-cp311-cp311-win_amd64.whl", hash = "sha256:a7639f51c184c0272e93f244eb24dafca9b1855707d94c192d4a0b4c01e1100e", size = 9227547 }, + { url = "https://files.pythonhosted.org/packages/91/20/f744bff1da8f43388498503634378dbbefbe493e65675f2cc52f7185c2c2/pywin32-306-cp311-cp311-win_arm64.whl", hash = "sha256:70dba0c913d19f942a2db25217d9a1b726c278f483a919f1abfed79c9cf64d3a", size = 10388324 }, + { url = "https://files.pythonhosted.org/packages/14/91/17e016d5923e178346aabda3dfec6629d1a26efe587d19667542105cf0a6/pywin32-306-cp312-cp312-win32.whl", hash = "sha256:383229d515657f4e3ed1343da8be101000562bf514591ff383ae940cad65458b", size = 8507705 }, + { url = "https://files.pythonhosted.org/packages/83/1c/25b79fc3ec99b19b0a0730cc47356f7e2959863bf9f3cd314332bddb4f68/pywin32-306-cp312-cp312-win_amd64.whl", hash = "sha256:37257794c1ad39ee9be652da0462dc2e394c8159dfd913a8a4e8eb6fd346da0e", size = 9227429 }, + { url = "https://files.pythonhosted.org/packages/1c/43/e3444dc9a12f8365d9603c2145d16bf0a2f8180f343cf87be47f5579e547/pywin32-306-cp312-cp312-win_arm64.whl", hash = "sha256:5821ec52f6d321aa59e2db7e0a35b997de60c201943557d108af9d4ae1ec7040", size = 10388145 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/e5/af35f7ea75cf72f2cd079c95ee16797de7cd71f29ea7c68ae5ce7be1eda0/PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43", size = 125201 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/06/4beb652c0fe16834032e54f0956443d4cc797fe645527acee59e7deaa0a2/PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a", size = 189447 }, + { url = "https://files.pythonhosted.org/packages/5b/07/10033a403b23405a8fc48975444463d3d10a5c2736b7eb2550b07b367429/PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f", size = 169264 }, + { url = "https://files.pythonhosted.org/packages/f1/26/55e4f21db1f72eaef092015d9017c11510e7e6301c62a6cfee91295d13c6/PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938", size = 677003 }, + { url = "https://files.pythonhosted.org/packages/ba/91/090818dfa62e85181f3ae23dd1e8b7ea7f09684864a900cab72d29c57346/PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d", size = 699070 }, + { url = "https://files.pythonhosted.org/packages/29/61/bf33c6c85c55bc45a29eee3195848ff2d518d84735eb0e2d8cb42e0d285e/PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515", size = 705525 }, + { url = "https://files.pythonhosted.org/packages/07/91/45dfd0ef821a7f41d9d0136ea3608bb5b1653e42fd56a7970532cb5c003f/PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290", size = 707514 }, + { url = "https://files.pythonhosted.org/packages/b6/a0/b6700da5d49e9fed49dc3243d3771b598dad07abb37cc32e524607f96adc/PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924", size = 130488 }, + { url = "https://files.pythonhosted.org/packages/24/97/9b59b43431f98d01806b288532da38099cc6f2fea0f3d712e21e269c0279/PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d", size = 145338 }, + { url = "https://files.pythonhosted.org/packages/ec/0d/26fb23e8863e0aeaac0c64e03fd27367ad2ae3f3cccf3798ee98ce160368/PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007", size = 187867 }, + { url = "https://files.pythonhosted.org/packages/28/09/55f715ddbf95a054b764b547f617e22f1d5e45d83905660e9a088078fe67/PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab", size = 167530 }, + { url = "https://files.pythonhosted.org/packages/5e/94/7d5ee059dfb92ca9e62f4057dcdec9ac08a9e42679644854dc01177f8145/PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d", size = 732244 }, + { url = "https://files.pythonhosted.org/packages/06/92/e0224aa6ebf9dc54a06a4609da37da40bb08d126f5535d81bff6b417b2ae/PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc", size = 752871 }, + { url = "https://files.pythonhosted.org/packages/7b/5e/efd033ab7199a0b2044dab3b9f7a4f6670e6a52c089de572e928d2873b06/PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673", size = 757729 }, + { url = "https://files.pythonhosted.org/packages/03/5c/c4671451b2f1d76ebe352c0945d4cd13500adb5d05f5a51ee296d80152f7/PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b", size = 748528 }, + { url = "https://files.pythonhosted.org/packages/73/9c/766e78d1efc0d1fca637a6b62cea1b4510a7fb93617eb805223294fef681/PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741", size = 130286 }, + { url = "https://files.pythonhosted.org/packages/b3/34/65bb4b2d7908044963ebf614fe0fdb080773fc7030d7e39c8d3eddcd4257/PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34", size = 144699 }, + { url = "https://files.pythonhosted.org/packages/bc/06/1b305bf6aa704343be85444c9d011f626c763abb40c0edc1cad13bfd7f86/PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28", size = 178692 }, + { url = "https://files.pythonhosted.org/packages/84/02/404de95ced348b73dd84f70e15a41843d817ff8c1744516bf78358f2ffd2/PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9", size = 165622 }, + { url = "https://files.pythonhosted.org/packages/c7/4c/4a2908632fc980da6d918b9de9c1d9d7d7e70b2672b1ad5166ed27841ef7/PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef", size = 696937 }, + { url = "https://files.pythonhosted.org/packages/b4/33/720548182ffa8344418126017aa1d4ab4aeec9a2275f04ce3f3573d8ace8/PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0", size = 724969 }, + { url = "https://files.pythonhosted.org/packages/4f/78/77b40157b6cb5f2d3d31a3d9b2efd1ba3505371f76730d267e8b32cf4b7f/PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4", size = 712604 }, + { url = "https://files.pythonhosted.org/packages/2e/97/3e0e089ee85e840f4b15bfa00e4e63d84a3691ababbfea92d6f820ea6f21/PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54", size = 126098 }, + { url = "https://files.pythonhosted.org/packages/2b/9f/fbade56564ad486809c27b322d0f7e6a89c01f6b4fe208402e90d4443a99/PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df", size = 138675 }, +] + +[[package]] +name = "pyzmq" +version = "25.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/33/1a3683fc9a4bd64d8ccc0290da75c8f042184a1a49c146d28398414d3341/pyzmq-25.1.2.tar.gz", hash = "sha256:93f1aa311e8bb912e34f004cf186407a4e90eec4f0ecc0efd26056bf7eda0226", size = 1402339 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/f4/901edb48b2b2c00ad73de0db2ee76e24ce5903ef815ad0ad10e14555d989/pyzmq-25.1.2-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:e624c789359f1a16f83f35e2c705d07663ff2b4d4479bad35621178d8f0f6ea4", size = 1872310 }, + { url = "https://files.pythonhosted.org/packages/5e/46/2de69c7c79fd78bf4c22a9e8165fa6312f5d49410f1be6ddab51a6fe7236/pyzmq-25.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:49151b0efece79f6a79d41a461d78535356136ee70084a1c22532fc6383f4ad0", size = 1249619 }, + { url = "https://files.pythonhosted.org/packages/d1/f5/d6b9755713843bf9701ae86bf6fd97ec294a52cf2af719cd14fdf9392f65/pyzmq-25.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9a5f194cf730f2b24d6af1f833c14c10f41023da46a7f736f48b6d35061e76e", size = 897360 }, + { url = "https://files.pythonhosted.org/packages/7c/88/c1aef8820f12e710d136024d231e70e24684a01314aa1814f0758960ba01/pyzmq-25.1.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:faf79a302f834d9e8304fafdc11d0d042266667ac45209afa57e5efc998e3872", size = 1156959 }, + { url = "https://files.pythonhosted.org/packages/82/1b/b25d2c4ac3b4dae238c98e63395dbb88daf11968b168948d3c6289c3e95c/pyzmq-25.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f51a7b4ead28d3fca8dda53216314a553b0f7a91ee8fc46a72b402a78c3e43d", size = 1100585 }, + { url = "https://files.pythonhosted.org/packages/67/bf/6bc0977acd934b66eacab79cec303ecf08ae4a6150d57c628aa919615488/pyzmq-25.1.2-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:0ddd6d71d4ef17ba5a87becf7ddf01b371eaba553c603477679ae817a8d84d75", size = 1109267 }, + { url = "https://files.pythonhosted.org/packages/64/fb/4f07424e56c6a5fb47306d9ba744c3c250250c2e7272f9c81efbf8daaccf/pyzmq-25.1.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:246747b88917e4867e2367b005fc8eefbb4a54b7db363d6c92f89d69abfff4b6", size = 1431853 }, + { url = "https://files.pythonhosted.org/packages/a2/10/2b88c1d4beb59a1d45c13983c4b7c5dcd6ef7988db3c03d23b0cabc5adca/pyzmq-25.1.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:00c48ae2fd81e2a50c3485de1b9d5c7c57cd85dc8ec55683eac16846e57ac979", size = 1766212 }, + { url = "https://files.pythonhosted.org/packages/bc/ab/c9a22eacfd5bd82620501ae426a3dd6ffa374ac335b21e54209d7a93d3fb/pyzmq-25.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5a68d491fc20762b630e5db2191dd07ff89834086740f70e978bb2ef2668be08", size = 1653737 }, + { url = "https://files.pythonhosted.org/packages/d6/e5/71bd89e47eedb7ebec31ef9a49dcdb0517dbbb063bd5de363980a6911eb1/pyzmq-25.1.2-cp310-cp310-win32.whl", hash = "sha256:09dfe949e83087da88c4a76767df04b22304a682d6154de2c572625c62ad6886", size = 906288 }, + { url = "https://files.pythonhosted.org/packages/9d/5f/2defc8a579e8b5679d92720ab3a4cb93e3a77d923070bf4c1a103d3ae478/pyzmq-25.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:fa99973d2ed20417744fca0073390ad65ce225b546febb0580358e36aa90dba6", size = 1170923 }, + { url = "https://files.pythonhosted.org/packages/35/de/7579518bc58cebf92568b48e354a702fb52525d0fab166dc544f2a0615dc/pyzmq-25.1.2-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:82544e0e2d0c1811482d37eef297020a040c32e0687c1f6fc23a75b75db8062c", size = 1870360 }, + { url = "https://files.pythonhosted.org/packages/ce/f9/58b6cc9a110b1832f666fa6b5a67dc4d520fabfc680ca87a8167b2061d5d/pyzmq-25.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:01171fc48542348cd1a360a4b6c3e7d8f46cdcf53a8d40f84db6707a6768acc1", size = 1249008 }, + { url = "https://files.pythonhosted.org/packages/bc/4a/ac6469c01813cb3652ab4e30ec4a37815cc9949afc18af33f64e2ec704aa/pyzmq-25.1.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc69c96735ab501419c432110016329bf0dea8898ce16fab97c6d9106dc0b348", size = 904394 }, + { url = "https://files.pythonhosted.org/packages/77/b7/8cee519b11bdd3f76c1a6eb537ab13c1bfef2964d725717705c86f524e4c/pyzmq-25.1.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3e124e6b1dd3dfbeb695435dff0e383256655bb18082e094a8dd1f6293114642", size = 1161453 }, + { url = "https://files.pythonhosted.org/packages/b6/1d/c35a956a44b333b064ae1b1c588c2dfa0e01b7ec90884c1972bfcef119c3/pyzmq-25.1.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7598d2ba821caa37a0f9d54c25164a4fa351ce019d64d0b44b45540950458840", size = 1105501 }, + { url = "https://files.pythonhosted.org/packages/18/d1/b3d1e985318ed7287737ea9e6b6e21748cc7c89accc2443347cd2c8d5f0f/pyzmq-25.1.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d1299d7e964c13607efd148ca1f07dcbf27c3ab9e125d1d0ae1d580a1682399d", size = 1109513 }, + { url = "https://files.pythonhosted.org/packages/14/9b/341cdfb47440069010101403298dc24d449150370c6cb322e73bfa1949bd/pyzmq-25.1.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4e6f689880d5ad87918430957297c975203a082d9a036cc426648fcbedae769b", size = 1433541 }, + { url = "https://files.pythonhosted.org/packages/fa/52/c6d4e76e020c554e965459d41a98201b4d45277a288648f53a4e5a2429cc/pyzmq-25.1.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cc69949484171cc961e6ecd4a8911b9ce7a0d1f738fcae717177c231bf77437b", size = 1766133 }, + { url = "https://files.pythonhosted.org/packages/1d/6d/0cbd8dd5b8979fd6b9cf1852ed067b9d2cd6fa0c09c3bafe6874d2d2e03c/pyzmq-25.1.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9880078f683466b7f567b8624bfc16cad65077be046b6e8abb53bed4eeb82dd3", size = 1653636 }, + { url = "https://files.pythonhosted.org/packages/f5/af/d90eed9cf3840685d54d4a35d5f9e242a8a48b5410d41146f14c1e098302/pyzmq-25.1.2-cp311-cp311-win32.whl", hash = "sha256:4e5837af3e5aaa99a091302df5ee001149baff06ad22b722d34e30df5f0d9097", size = 904865 }, + { url = "https://files.pythonhosted.org/packages/20/d2/09443dc73053ad01c846d7fb77e09fe9d93c09d4e900215f3c8b7b56bfec/pyzmq-25.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:25c2dbb97d38b5ac9fd15586e048ec5eb1e38f3d47fe7d92167b0c77bb3584e9", size = 1171332 }, + { url = "https://files.pythonhosted.org/packages/6e/f0/d71cf69dc039c9adc8b625efc3bad3684f3660a570e47f0f0c64df787b41/pyzmq-25.1.2-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:11e70516688190e9c2db14fcf93c04192b02d457b582a1f6190b154691b4c93a", size = 1871111 }, + { url = "https://files.pythonhosted.org/packages/68/62/d365773edf56ad71993579ee574105f02f83530caf600ebf28bea15d88d0/pyzmq-25.1.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:313c3794d650d1fccaaab2df942af9f2c01d6217c846177cfcbc693c7410839e", size = 1248844 }, + { url = "https://files.pythonhosted.org/packages/72/55/cc3163e20f40615a49245fa7041badec6103e8ee7e482dbb0feea00a7b84/pyzmq-25.1.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b3cbba2f47062b85fe0ef9de5b987612140a9ba3a9c6d2543c6dec9f7c2ab27", size = 899373 }, + { url = "https://files.pythonhosted.org/packages/40/aa/ae292bd85deda637230970bbc53c1dc53696a99e82fc7cd6d373ec173853/pyzmq-25.1.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fc31baa0c32a2ca660784d5af3b9487e13b61b3032cb01a115fce6588e1bed30", size = 1160901 }, + { url = "https://files.pythonhosted.org/packages/93/b7/6e291eafbbbc66d0e87658dd21383ec2b4ab35edcfb283902c580a6db76f/pyzmq-25.1.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02c9087b109070c5ab0b383079fa1b5f797f8d43e9a66c07a4b8b8bdecfd88ee", size = 1101147 }, + { url = "https://files.pythonhosted.org/packages/3a/f1/e296d5a507eac519d1fe1382851b1a4575f690bc2b2d2c8eca2ed7e4bd1f/pyzmq-25.1.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:f8429b17cbb746c3e043cb986328da023657e79d5ed258b711c06a70c2ea7537", size = 1105315 }, + { url = "https://files.pythonhosted.org/packages/56/63/5c2abb556ab4cf013d98e01782d5bd642238a0ed9b019e965a7d7e957f56/pyzmq-25.1.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5074adeacede5f810b7ef39607ee59d94e948b4fd954495bdb072f8c54558181", size = 1427747 }, + { url = "https://files.pythonhosted.org/packages/b1/71/5dba5f6b12ef54fb977c9b9279075e151c04fc0dd6851e9663d9e66b593f/pyzmq-25.1.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:7ae8f354b895cbd85212da245f1a5ad8159e7840e37d78b476bb4f4c3f32a9fe", size = 1762221 }, + { url = "https://files.pythonhosted.org/packages/cf/49/54d7e8bb3df82a3509325b11491d33450dc91580d4826b62fa5e554bb9cf/pyzmq-25.1.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b264bf2cc96b5bc43ce0e852be995e400376bd87ceb363822e2cb1964fcdc737", size = 1649505 }, + { url = "https://files.pythonhosted.org/packages/34/14/58e5037229bc37963e2ce804c2c075a3a541e3f84bf1c231e7c9779d36f1/pyzmq-25.1.2-cp312-cp312-win32.whl", hash = "sha256:02bbc1a87b76e04fd780b45e7f695471ae6de747769e540da909173d50ff8e2d", size = 954891 }, + { url = "https://files.pythonhosted.org/packages/2c/2d/04fab685ef3a8e6e955220fd2a54dc99efaee960a88675bf5c92cd277164/pyzmq-25.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:ced111c2e81506abd1dc142e6cd7b68dd53747b3b7ae5edbea4578c5eeff96b7", size = 1252773 }, + { url = "https://files.pythonhosted.org/packages/6b/fe/ed38fe12c540bafc1cae32c3ff638e9df32528f5cf91b5e400e6a8f5b3ec/pyzmq-25.1.2-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a8c1d566344aee826b74e472e16edae0a02e2a044f14f7c24e123002dcff1c05", size = 963654 }, + { url = "https://files.pythonhosted.org/packages/44/97/a760a2dff0672c408f22f726f2ea10a7a516ffa5001ca5a3641e355a45f9/pyzmq-25.1.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:759cfd391a0996345ba94b6a5110fca9c557ad4166d86a6e81ea526c376a01e8", size = 609436 }, + { url = "https://files.pythonhosted.org/packages/41/81/ace39daa19c78b2f4fc12ef217d9d5f1ac658d5828d692bbbb68240cd55b/pyzmq-25.1.2-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c61e346ac34b74028ede1c6b4bcecf649d69b707b3ff9dc0fab453821b04d1e", size = 843396 }, + { url = "https://files.pythonhosted.org/packages/4c/43/150b0b203f5461a9aeadaa925c55167e2b4215c9322b6911a64360d2243e/pyzmq-25.1.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4cb8fc1f8d69b411b8ec0b5f1ffbcaf14c1db95b6bccea21d83610987435f1a4", size = 800856 }, + { url = "https://files.pythonhosted.org/packages/5f/91/a618b56aaabe40dddcd25db85624d7408768fd32f5bfcf81bc0af5b1ce75/pyzmq-25.1.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:3c00c9b7d1ca8165c610437ca0c92e7b5607b2f9076f4eb4b095c85d6e680a1d", size = 413836 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/a1/a5f4bebaa31d109003909809d88aeb0d4b201463a9ea29308d9e4f9e7655/qudida-0.0.4-py3-none-any.whl", hash = "sha256:4519714c40cd0f2e6c51e1735edae8f8b19f4efe1f33be13e9d644ca5f736dd6", size = 3478 }, +] + +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, +] + +[[package]] +name = "rich" +version = "13.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, +] + +[[package]] +name = "rknn-toolkit-lite2" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "psutil" }, + { name = "ruamel-yaml" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/77/6af374a4a8cd2aee762a1fb8a3050dcf3f129134bbdc4bb6bed755c4325b/rknn_toolkit_lite2-2.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b6733689bd09a262bcb6ba4744e690dd4b37ebeac4ed427cf45242c4b4ce9a4", size = 559372 }, + { url = "https://files.pythonhosted.org/packages/9b/0c/76ff1eb09d09ce4394a6959d2343a321d28dd9e604348ffdafceafdc344c/rknn_toolkit_lite2-2.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3e4fefe355dc34a155680e4bcb9e4abb37ebc271f045ec9e0a4a3a018bc5beb", size = 569149 }, + { url = "https://files.pythonhosted.org/packages/0d/6e/8679562028051b02312212defc6e8c07248953f10dd7ad506e941b575bf3/rknn_toolkit_lite2-2.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37394371d1561f470c553f39869d7c35ff93405dffe3d0d72babf297a2b0aee9", size = 527457 }, +] + +[[package]] +name = "ruamel-yaml" +version = "0.18.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ruamel-yaml-clib", marker = "python_full_version < '3.13' and platform_python_implementation == 'CPython'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/46/f44d8be06b85bc7c4d8c95d658be2b68f27711f279bf9dd0612a5e4794f5/ruamel.yaml-0.18.10.tar.gz", hash = "sha256:20c86ab29ac2153f80a428e1254a8adf686d3383df04490514ca3b79a362db58", size = 143447 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/36/dfc1ebc0081e6d39924a2cc53654497f967a084a436bb64402dfce4254d9/ruamel.yaml-0.18.10-py3-none-any.whl", hash = "sha256:30f22513ab2301b3d2b577adc121c6471f28734d3d9728581245f1e76468b4f1", size = 117729 }, +] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/84/80203abff8ea4993a87d823a5f632e4d92831ef75d404c9fc78d0176d2b5/ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f", size = 225315 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/57/40a958e863e299f0c74ef32a3bde9f2d1ea8d69669368c0c502a0997f57f/ruamel.yaml.clib-0.2.12-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:11f891336688faf5156a36293a9c362bdc7c88f03a8a027c2c1d8e0bcde998e5", size = 131301 }, + { url = "https://files.pythonhosted.org/packages/98/a8/29a3eb437b12b95f50a6bcc3d7d7214301c6c529d8fdc227247fa84162b5/ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:a606ef75a60ecf3d924613892cc603b154178ee25abb3055db5062da811fd969", size = 633728 }, + { url = "https://files.pythonhosted.org/packages/35/6d/ae05a87a3ad540259c3ad88d71275cbd1c0f2d30ae04c65dcbfb6dcd4b9f/ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd5415dded15c3822597455bc02bcd66e81ef8b7a48cb71a33628fc9fdde39df", size = 722230 }, + { url = "https://files.pythonhosted.org/packages/7f/b7/20c6f3c0b656fe609675d69bc135c03aac9e3865912444be6339207b6648/ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76", size = 686712 }, + { url = "https://files.pythonhosted.org/packages/cd/11/d12dbf683471f888d354dac59593873c2b45feb193c5e3e0f2ebf85e68b9/ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6", size = 663936 }, + { url = "https://files.pythonhosted.org/packages/72/14/4c268f5077db5c83f743ee1daeb236269fa8577133a5cfa49f8b382baf13/ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd", size = 696580 }, + { url = "https://files.pythonhosted.org/packages/30/fc/8cd12f189c6405a4c1cf37bd633aa740a9538c8e40497c231072d0fef5cf/ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a", size = 663393 }, + { url = "https://files.pythonhosted.org/packages/80/29/c0a017b704aaf3cbf704989785cd9c5d5b8ccec2dae6ac0c53833c84e677/ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da", size = 100326 }, + { url = "https://files.pythonhosted.org/packages/3a/65/fa39d74db4e2d0cd252355732d966a460a41cd01c6353b820a0952432839/ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28", size = 118079 }, + { url = "https://files.pythonhosted.org/packages/fb/8f/683c6ad562f558cbc4f7c029abcd9599148c51c54b5ef0f24f2638da9fbb/ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6", size = 132224 }, + { url = "https://files.pythonhosted.org/packages/3c/d2/b79b7d695e2f21da020bd44c782490578f300dd44f0a4c57a92575758a76/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:d84318609196d6bd6da0edfa25cedfbabd8dbde5140a0a23af29ad4b8f91fb1e", size = 641480 }, + { url = "https://files.pythonhosted.org/packages/68/6e/264c50ce2a31473a9fdbf4fa66ca9b2b17c7455b31ef585462343818bd6c/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb43a269eb827806502c7c8efb7ae7e9e9d0573257a46e8e952f4d4caba4f31e", size = 739068 }, + { url = "https://files.pythonhosted.org/packages/86/29/88c2567bc893c84d88b4c48027367c3562ae69121d568e8a3f3a8d363f4d/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52", size = 703012 }, + { url = "https://files.pythonhosted.org/packages/11/46/879763c619b5470820f0cd6ca97d134771e502776bc2b844d2adb6e37753/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642", size = 704352 }, + { url = "https://files.pythonhosted.org/packages/02/80/ece7e6034256a4186bbe50dee28cd032d816974941a6abf6a9d65e4228a7/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2", size = 737344 }, + { url = "https://files.pythonhosted.org/packages/f0/ca/e4106ac7e80efbabdf4bf91d3d32fc424e41418458251712f5672eada9ce/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3", size = 714498 }, + { url = "https://files.pythonhosted.org/packages/67/58/b1f60a1d591b771298ffa0428237afb092c7f29ae23bad93420b1eb10703/ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4", size = 100205 }, + { url = "https://files.pythonhosted.org/packages/b4/4f/b52f634c9548a9291a70dfce26ca7ebce388235c93588a1068028ea23fcc/ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb", size = 118185 }, + { url = "https://files.pythonhosted.org/packages/48/41/e7a405afbdc26af961678474a55373e1b323605a4f5e2ddd4a80ea80f628/ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632", size = 133433 }, + { url = "https://files.pythonhosted.org/packages/ec/b0/b850385604334c2ce90e3ee1013bd911aedf058a934905863a6ea95e9eb4/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:943f32bc9dedb3abff9879edc134901df92cfce2c3d5c9348f172f62eb2d771d", size = 647362 }, + { url = "https://files.pythonhosted.org/packages/44/d0/3f68a86e006448fb6c005aee66565b9eb89014a70c491d70c08de597f8e4/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c3829bb364fdb8e0332c9931ecf57d9be3519241323c5274bd82f709cebc0c", size = 754118 }, + { url = "https://files.pythonhosted.org/packages/52/a9/d39f3c5ada0a3bb2870d7db41901125dbe2434fa4f12ca8c5b83a42d7c53/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd", size = 706497 }, + { url = "https://files.pythonhosted.org/packages/b0/fa/097e38135dadd9ac25aecf2a54be17ddf6e4c23e43d538492a90ab3d71c6/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31", size = 698042 }, + { url = "https://files.pythonhosted.org/packages/ec/d5/a659ca6f503b9379b930f13bc6b130c9f176469b73b9834296822a83a132/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680", size = 745831 }, + { url = "https://files.pythonhosted.org/packages/db/5d/36619b61ffa2429eeaefaab4f3374666adf36ad8ac6330d855848d7d36fd/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d", size = 715692 }, + { url = "https://files.pythonhosted.org/packages/b1/82/85cb92f15a4231c89b95dfe08b09eb6adca929ef7df7e17ab59902b6f589/ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5", size = 98777 }, + { url = "https://files.pythonhosted.org/packages/d7/8f/c3654f6f1ddb75daf3922c3d8fc6005b1ab56671ad56ffb874d908bfa668/ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4", size = 115523 }, + { url = "https://files.pythonhosted.org/packages/29/00/4864119668d71a5fa45678f380b5923ff410701565821925c69780356ffa/ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a", size = 132011 }, + { url = "https://files.pythonhosted.org/packages/7f/5e/212f473a93ae78c669ffa0cb051e3fee1139cb2d385d2ae1653d64281507/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:e7e3736715fbf53e9be2a79eb4db68e4ed857017344d697e8b9749444ae57475", size = 642488 }, + { url = "https://files.pythonhosted.org/packages/1f/8f/ecfbe2123ade605c49ef769788f79c38ddb1c8fa81e01f4dbf5cf1a44b16/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7e75b4965e1d4690e93021adfcecccbca7d61c7bddd8e22406ef2ff20d74ef", size = 745066 }, + { url = "https://files.pythonhosted.org/packages/e2/a9/28f60726d29dfc01b8decdb385de4ced2ced9faeb37a847bd5cf26836815/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6", size = 701785 }, + { url = "https://files.pythonhosted.org/packages/84/7e/8e7ec45920daa7f76046578e4f677a3215fe8f18ee30a9cb7627a19d9b4c/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf", size = 693017 }, + { url = "https://files.pythonhosted.org/packages/c5/b3/d650eaade4ca225f02a648321e1ab835b9d361c60d51150bac49063b83fa/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1", size = 741270 }, + { url = "https://files.pythonhosted.org/packages/87/b8/01c29b924dcbbed75cc45b30c30d565d763b9c4d540545a0eeecffb8f09c/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01", size = 709059 }, + { url = "https://files.pythonhosted.org/packages/30/8c/ed73f047a73638257aa9377ad356bea4d96125b305c34a28766f4445cc0f/ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6", size = 98583 }, + { url = "https://files.pythonhosted.org/packages/b0/85/e8e751d8791564dd333d5d9a4eab0a7a115f7e349595417fd50ecae3395c/ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3", size = 115190 }, +] + +[[package]] +name = "ruff" +version = "0.9.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/c3/418441a8170e8d53d05c0b9dad69760dbc7b8a12c10dbe6db1e1205d2377/ruff-0.9.9.tar.gz", hash = "sha256:0062ed13f22173e85f8f7056f9a24016e692efeea8704d1a5e8011b8aa850933", size = 3717448 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/c3/2c4afa9ba467555d074b146d9aed0633a56ccdb900839fb008295d037b89/ruff-0.9.9-py3-none-linux_armv6l.whl", hash = "sha256:628abb5ea10345e53dff55b167595a159d3e174d6720bf19761f5e467e68d367", size = 10027252 }, + { url = "https://files.pythonhosted.org/packages/33/d1/439e58487cf9eac26378332e25e7d5ade4b800ce1eec7dc2cfc9b0d7ca96/ruff-0.9.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b6cd1428e834b35d7493354723543b28cc11dc14d1ce19b685f6e68e07c05ec7", size = 10840721 }, + { url = "https://files.pythonhosted.org/packages/50/44/fead822c38281ba0122f1b76b460488a175a9bd48b130650a6fb6dbcbcf9/ruff-0.9.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5ee162652869120ad260670706f3cd36cd3f32b0c651f02b6da142652c54941d", size = 10161439 }, + { url = "https://files.pythonhosted.org/packages/11/ae/d404a2ab8e61ddf6342e09cc6b7f7846cce6b243e45c2007dbe0ca928a5d/ruff-0.9.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3aa0f6b75082c9be1ec5a1db78c6d4b02e2375c3068438241dc19c7c306cc61a", size = 10336264 }, + { url = "https://files.pythonhosted.org/packages/6a/4e/7c268aa7d84cd709fb6f046b8972313142cffb40dfff1d2515c5e6288d54/ruff-0.9.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:584cc66e89fb5f80f84b05133dd677a17cdd86901d6479712c96597a3f28e7fe", size = 9908774 }, + { url = "https://files.pythonhosted.org/packages/cc/26/c618a878367ef1b76270fd027ca93692657d3f6122b84ba48911ef5f2edc/ruff-0.9.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abf3369325761a35aba75cd5c55ba1b5eb17d772f12ab168fbfac54be85cf18c", size = 11428127 }, + { url = "https://files.pythonhosted.org/packages/d7/9a/c5588a93d9bfed29f565baf193fe802fa676a0c837938137ea6cf0576d8c/ruff-0.9.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3403a53a32a90ce929aa2f758542aca9234befa133e29f4933dcef28a24317be", size = 12133187 }, + { url = "https://files.pythonhosted.org/packages/3e/ff/e7980a7704a60905ed7e156a8d73f604c846d9bd87deda9cabfa6cba073a/ruff-0.9.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:18454e7fa4e4d72cffe28a37cf6a73cb2594f81ec9f4eca31a0aaa9ccdfb1590", size = 11602937 }, + { url = "https://files.pythonhosted.org/packages/24/78/3690444ad9e3cab5c11abe56554c35f005b51d1d118b429765249095269f/ruff-0.9.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fadfe2c88724c9617339f62319ed40dcdadadf2888d5afb88bf3adee7b35bfb", size = 13771698 }, + { url = "https://files.pythonhosted.org/packages/6e/bf/e477c2faf86abe3988e0b5fd22a7f3520e820b2ee335131aca2e16120038/ruff-0.9.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6df104d08c442a1aabcfd254279b8cc1e2cbf41a605aa3e26610ba1ec4acf0b0", size = 11249026 }, + { url = "https://files.pythonhosted.org/packages/f7/82/cdaffd59e5a8cb5b14c408c73d7a555a577cf6645faaf83e52fe99521715/ruff-0.9.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d7c62939daf5b2a15af48abbd23bea1efdd38c312d6e7c4cedf5a24e03207e17", size = 10220432 }, + { url = "https://files.pythonhosted.org/packages/fe/a4/2507d0026225efa5d4412b6e294dfe54725a78652a5c7e29e6bd0fc492f3/ruff-0.9.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9494ba82a37a4b81b6a798076e4a3251c13243fc37967e998efe4cce58c8a8d1", size = 9874602 }, + { url = "https://files.pythonhosted.org/packages/d5/be/f3aab1813846b476c4bcffe052d232244979c3cd99d751c17afb530ca8e4/ruff-0.9.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4efd7a96ed6d36ef011ae798bf794c5501a514be369296c672dab7921087fa57", size = 10851212 }, + { url = "https://files.pythonhosted.org/packages/8b/45/8e5fd559bea0d2f57c4e12bf197a2fade2fac465aa518284f157dfbca92b/ruff-0.9.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ab90a7944c5a1296f3ecb08d1cbf8c2da34c7e68114b1271a431a3ad30cb660e", size = 11327490 }, + { url = "https://files.pythonhosted.org/packages/42/55/e6c90f13880aeef327746052907e7e930681f26a164fe130ddac28b08269/ruff-0.9.9-py3-none-win32.whl", hash = "sha256:6b4c376d929c25ecd6d87e182a230fa4377b8e5125a4ff52d506ee8c087153c1", size = 10227912 }, + { url = "https://files.pythonhosted.org/packages/35/b2/da925693cb82a1208aa34966c0f36cb222baca94e729dd22a587bc22d0f3/ruff-0.9.9-py3-none-win_amd64.whl", hash = "sha256:837982ea24091d4c1700ddb2f63b7070e5baec508e43b01de013dc7eff974ff1", size = 11355632 }, + { url = "https://files.pythonhosted.org/packages/31/d8/de873d1c1b020d668d8ec9855d390764cb90cf8f6486c0983da52be8b7b7/ruff-0.9.9-py3-none-win_arm64.whl", hash = "sha256:3ac78f127517209fe6d96ab00f3ba97cafe38718b23b1db3e96d8b2d39e37ddf", size = 10435860 }, +] + +[[package]] +name = "scikit-image" +version = "0.22.0" +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/65/c1/a49da20845f0f0e1afbb1c2586d406dc0acb84c26ae293bad6d7e7f718bc/scikit_image-0.22.0.tar.gz", hash = "sha256:018d734df1d2da2719087d15f679d19285fce97cd37695103deadfaef2873236", size = 22685018 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/8c/381ae42b37cf3e9e99a1deb3ffe76ca5ff5dd18ffa368293476164507fad/scikit_image-0.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:74ec5c1d4693506842cc7c9487c89d8fc32aed064e9363def7af08b8f8cbb31d", size = 13905039 }, + { url = "https://files.pythonhosted.org/packages/16/06/4bfba08f5cce26d5070bb2cf4e3f9f479480978806355d1c5bea6f26a17c/scikit_image-0.22.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:a05ae4fe03d802587ed8974e900b943275548cde6a6807b785039d63e9a7a5ff", size = 13279212 }, + { url = "https://files.pythonhosted.org/packages/74/57/dbf744ca00eea2a09b1848c9dec28a43978c16dc049b1fba949cb050bedf/scikit_image-0.22.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a92dca3d95b1301442af055e196a54b5a5128c6768b79fc0a4098f1d662dee6", size = 14091779 }, + { url = "https://files.pythonhosted.org/packages/f1/6c/49f5a0ce8ddcdbdac5ac69c129654938cc6de0a936303caa6cad495ceb2a/scikit_image-0.22.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3663d063d8bf2fb9bdfb0ca967b9ee3b6593139c860c7abc2d2351a8a8863938", size = 14682042 }, + { url = "https://files.pythonhosted.org/packages/86/f0/18895318109f9b508f2310f136922e455a453550826a8240b412063c2528/scikit_image-0.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:ebdbdc901bae14dab637f8d5c99f6d5cc7aaf4a3b6f4003194e003e9f688a6fc", size = 24492345 }, + { url = "https://files.pythonhosted.org/packages/9f/d9/dc99e527d1a0050f0353d2fff3548273b4df6151884806e324f26572fd6b/scikit_image-0.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:95d6da2d8a44a36ae04437c76d32deb4e3c993ffc846b394b9949fd8ded73cb2", size = 13883619 }, + { url = "https://files.pythonhosted.org/packages/80/37/7670020b112ff9a47e49b1e36f438d000db5b632aab8a8fd7e6be545d065/scikit_image-0.22.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:2c6ef454a85f569659b813ac2a93948022b0298516b757c9c6c904132be327e2", size = 13264761 }, + { url = "https://files.pythonhosted.org/packages/ad/85/dadf1194793ac1c895370f3ed048bb91dda083775b42e11d9672a50494d5/scikit_image-0.22.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e87872f067444ee90a00dd49ca897208308645382e8a24bd3e76f301af2352cd", size = 14070710 }, + { url = "https://files.pythonhosted.org/packages/d4/34/e27bf2bfe7b52b884b49bd71ea91ff81e4737246735ee5ea383314c31876/scikit_image-0.22.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5c378db54e61b491b9edeefff87e49fcf7fdf729bb93c777d7a5f15d36f743e", size = 14664172 }, + { url = "https://files.pythonhosted.org/packages/ce/d0/a3f60c9f57ed295b3076e4acdb29a37bbd8823452562ab2ad51b03d6f377/scikit_image-0.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:2bcb74adb0634258a67f66c2bb29978c9a3e222463e003b67ba12056c003971b", size = 24491321 }, + { url = "https://files.pythonhosted.org/packages/da/a4/b0b69bde4d6360e801d647691591dc9967a25a18a4c63ecf7f87d94e3fac/scikit_image-0.22.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:003ca2274ac0fac252280e7179ff986ff783407001459ddea443fe7916e38cff", size = 13968808 }, + { url = "https://files.pythonhosted.org/packages/e4/65/3c0f77e7a9bae100a8f7f5cebde410fca1a3cf64e1ecdd343666e27b11d4/scikit_image-0.22.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:cf3c0c15b60ae3e557a0c7575fbd352f0c3ce0afca562febfe3ab80efbeec0e9", size = 13323763 }, + { url = "https://files.pythonhosted.org/packages/4a/ed/7faf9f7a55d5b3095d33990a85603b66866cce2a608b27f0e1487d70a451/scikit_image-0.22.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5b23908dd4d120e6aecb1ed0277563e8cbc8d6c0565bdc4c4c6475d53608452", size = 13877233 }, + { url = "https://files.pythonhosted.org/packages/ae/9d/09d06f36ce71fa276e1d9453fb4b04250a7038292b13b8c273a5a1a8f7c0/scikit_image-0.22.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be79d7493f320a964f8fcf603121595ba82f84720de999db0fcca002266a549a", size = 14954814 }, + { url = "https://files.pythonhosted.org/packages/dc/35/e6327ae498c6f557cb0a7c3fc284effe7958d2d1c43fb61cd77804fc2c4f/scikit_image-0.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:722b970aa5da725dca55252c373b18bbea7858c1cdb406e19f9b01a4a73b30b2", size = 25004857 }, +] + +[[package]] +name = "scikit-learn" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/00/835e3d280fdd7784e76bdef91dd9487582d7951a7254f59fc8004fc8b213/scikit-learn-1.3.2.tar.gz", hash = "sha256:a2f54c76accc15a34bfb9066e6c7a56c1e7235dda5762b990792330b52ccfb05", size = 7510251 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/53/570b55a6e10b8694ac1e3024d2df5cd443f1b4ff6d28430845da8b9019b3/scikit_learn-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e326c0eb5cf4d6ba40f93776a20e9a7a69524c4db0757e7ce24ba222471ee8a1", size = 10209999 }, + { url = "https://files.pythonhosted.org/packages/70/d0/50ace22129f79830e3cf682d0a2bd4843ef91573299d43112d52790163a8/scikit_learn-1.3.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:535805c2a01ccb40ca4ab7d081d771aea67e535153e35a1fd99418fcedd1648a", size = 9479353 }, + { url = "https://files.pythonhosted.org/packages/8f/46/fcc35ed7606c50d3072eae5a107a45cfa5b7f5fa8cc48610edd8cc8e8550/scikit_learn-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1215e5e58e9880b554b01187b8c9390bf4dc4692eedeaf542d3273f4785e342c", size = 10304705 }, + { url = "https://files.pythonhosted.org/packages/d0/0b/26ad95cf0b747be967b15fb71a06f5ac67aba0fd2f9cd174de6edefc4674/scikit_learn-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ee107923a623b9f517754ea2f69ea3b62fc898a3641766cb7deb2f2ce450161", size = 10827807 }, + { url = "https://files.pythonhosted.org/packages/69/8a/cf17d6443f5f537e099be81535a56ab68a473f9393fbffda38cd19899fc8/scikit_learn-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:35a22e8015048c628ad099da9df5ab3004cdbf81edc75b396fd0cff8699ac58c", size = 9255427 }, + { url = "https://files.pythonhosted.org/packages/08/5d/e5acecd6e99a6b656e42e7a7b18284e2f9c9f512e8ed6979e1e75d25f05f/scikit_learn-1.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6fb6bc98f234fda43163ddbe36df8bcde1d13ee176c6dc9b92bb7d3fc842eb66", size = 10116376 }, + { url = "https://files.pythonhosted.org/packages/40/c6/2e91eefb757822e70d351e02cc38d07c137212ae7c41ac12746415b4860a/scikit_learn-1.3.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:18424efee518a1cde7b0b53a422cde2f6625197de6af36da0b57ec502f126157", size = 9383415 }, + { url = "https://files.pythonhosted.org/packages/fa/fd/b3637639e73bb72b12803c5245f2a7299e09b2acd85a0f23937c53369a1c/scikit_learn-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3271552a5eb16f208a6f7f617b8cc6d1f137b52c8a1ef8edf547db0259b2c9fb", size = 10279163 }, + { url = "https://files.pythonhosted.org/packages/0c/2a/d3ff6091406bc2207e0adb832ebd15e40ac685811c7e2e3b432bfd969b71/scikit_learn-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4144a5004a676d5022b798d9e573b05139e77f271253a4703eed295bde0433", size = 10884422 }, + { url = "https://files.pythonhosted.org/packages/4e/ba/ce9bd1cd4953336a0e213b29cb80bb11816f2a93de8c99f88ef0b446ad0c/scikit_learn-1.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:67f37d708f042a9b8d59551cf94d30431e01374e00dc2645fa186059c6c5d78b", size = 9207060 }, + { url = "https://files.pythonhosted.org/packages/26/7e/2c3b82c8c29aa384c8bf859740419278627d2cdd0050db503c8840e72477/scikit_learn-1.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8db94cd8a2e038b37a80a04df8783e09caac77cbe052146432e67800e430c028", size = 9979322 }, + { url = "https://files.pythonhosted.org/packages/cf/fc/6c52ffeb587259b6b893b7cac268f1eb1b5426bcce1aa20e53523bfe6944/scikit_learn-1.3.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:61a6efd384258789aa89415a410dcdb39a50e19d3d8410bd29be365bcdd512d5", size = 9270688 }, + { url = "https://files.pythonhosted.org/packages/e5/a7/6f4ae76f72ae9de162b97acbf1f53acbe404c555f968d13da21e4112a002/scikit_learn-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb06f8dce3f5ddc5dee1715a9b9f19f20d295bed8e3cd4fa51e1d050347de525", size = 10280398 }, + { url = "https://files.pythonhosted.org/packages/5d/b7/ee35904c07a0666784349529412fbb9814a56382b650d30fd9d6be5e5054/scikit_learn-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5b2de18d86f630d68fe1f87af690d451388bb186480afc719e5f770590c2ef6c", size = 10796478 }, + { url = "https://files.pythonhosted.org/packages/fe/6b/db949ed5ac367987b1f250f070f340b7715d22f0c9c965bdf07de6ca75a3/scikit_learn-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:0402638c9a7c219ee52c94cbebc8fcb5eb9fe9c773717965c1f4185588ad3107", size = 9133979 }, +] + +[[package]] +name = "scipy" +version = "1.11.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/1f/91144ba78dccea567a6466262922786ffc97be1e9b06ed9574ef0edc11e1/scipy-1.11.4.tar.gz", hash = "sha256:90a2b78e7f5733b9de748f589f09225013685f9b218275257f8a8168ededaeaa", size = 56336202 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/c6/a32add319475d21f89733c034b99c81b3a7c6c7c19f96f80c7ca3ff1bbd4/scipy-1.11.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc9a714581f561af0848e6b69947fda0614915f072dfd14142ed1bfe1b806710", size = 37293259 }, + { url = "https://files.pythonhosted.org/packages/de/0d/4fa68303568c70fd56fbf40668b6c6807cfee4cad975f07d80bdd26d013e/scipy-1.11.4-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:cf00bd2b1b0211888d4dc75656c0412213a8b25e80d73898083f402b50f47e41", size = 29760656 }, + { url = "https://files.pythonhosted.org/packages/13/e5/8012be7857db6cbbbdbeea8a154dbacdfae845e95e1e19c028e82236d4a0/scipy-1.11.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9999c008ccf00e8fbcce1236f85ade5c569d13144f77a1946bef8863e8f6eb4", size = 32922489 }, + { url = "https://files.pythonhosted.org/packages/e0/9e/80e2205d138960a49caea391f3710600895dd8292b6868dc9aff7aa593f9/scipy-1.11.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:933baf588daa8dc9a92c20a0be32f56d43faf3d1a60ab11b3f08c356430f6e56", size = 36442040 }, + { url = "https://files.pythonhosted.org/packages/69/60/30a9c3fbe5066a3a93eefe3e2d44553df13587e6f792e1bff20dfed3d17e/scipy-1.11.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8fce70f39076a5aa62e92e69a7f62349f9574d8405c0a5de6ed3ef72de07f446", size = 36643257 }, + { url = "https://files.pythonhosted.org/packages/f8/ec/b46756f80e3f4c5f0989f6e4492c2851f156d9c239d554754a3c8cffd4e2/scipy-1.11.4-cp310-cp310-win_amd64.whl", hash = "sha256:6550466fbeec7453d7465e74d4f4b19f905642c89a7525571ee91dd7adabb5a3", size = 44149285 }, + { url = "https://files.pythonhosted.org/packages/b8/f2/1aefbd5e54ebd8c6163ccf7f73e5d17bc8cb38738d312befc524fce84bb4/scipy-1.11.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f313b39a7e94f296025e3cffc2c567618174c0b1dde173960cf23808f9fae4be", size = 37159197 }, + { url = "https://files.pythonhosted.org/packages/4b/48/20e77ddb1f473d4717a7d4d3fc8d15557f406f7708496054c59f635b7734/scipy-1.11.4-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:1b7c3dca977f30a739e0409fb001056484661cb2541a01aba0bb0029f7b68db8", size = 29675057 }, + { url = "https://files.pythonhosted.org/packages/75/2e/a781862190d0e7e76afa74752ef363488a9a9d6ea86e46d5e5506cee8df6/scipy-1.11.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00150c5eae7b610c32589dda259eacc7c4f1665aedf25d921907f4d08a951b1c", size = 32882747 }, + { url = "https://files.pythonhosted.org/packages/6b/d4/d62ce38ba00dc67d7ec4ec5cc19d36958d8ed70e63778715ad626bcbc796/scipy-1.11.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:530f9ad26440e85766509dbf78edcfe13ffd0ab7fec2560ee5c36ff74d6269ff", size = 36402732 }, + { url = "https://files.pythonhosted.org/packages/88/86/827b56aea1ed04adbb044a675672a73c84d81076a350092bbfcfc1ae723b/scipy-1.11.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5e347b14fe01003d3b78e196e84bd3f48ffe4c8a7b8a1afbcb8f5505cb710993", size = 36622138 }, + { url = "https://files.pythonhosted.org/packages/43/d0/f3cd75b62e1b90f48dbf091261b2fc7ceec14a700e308c50f6a69c83d337/scipy-1.11.4-cp311-cp311-win_amd64.whl", hash = "sha256:acf8ed278cc03f5aff035e69cb511741e0418681d25fbbb86ca65429c4f4d9cd", size = 44095631 }, + { url = "https://files.pythonhosted.org/packages/df/64/8a690570485b636da614acff35fd725fcbc487f8b1fa9bdb12871b77412f/scipy-1.11.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:028eccd22e654b3ea01ee63705681ee79933652b2d8f873e7949898dda6d11b6", size = 37053653 }, + { url = "https://files.pythonhosted.org/packages/5e/43/abf331745a7e5f4af51f13d40e2a72f516048db41ecbcf3ac6f86ada54a3/scipy-1.11.4-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:2c6ff6ef9cc27f9b3db93a6f8b38f97387e6e0591600369a297a50a8e96e835d", size = 29641601 }, + { url = "https://files.pythonhosted.org/packages/47/9b/62d0ec086dd2871009da8769c504bec6e39b80f4c182c6ead0fcebd8b323/scipy-1.11.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b030c6674b9230d37c5c60ab456e2cf12f6784596d15ce8da9365e70896effc4", size = 32272137 }, + { url = "https://files.pythonhosted.org/packages/08/77/f90f7306d755ac68bd159c50bb86fffe38400e533e8c609dd8484bd0f172/scipy-1.11.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad669df80528aeca5f557712102538f4f37e503f0c5b9541655016dd0932ca79", size = 35777534 }, + { url = "https://files.pythonhosted.org/packages/00/de/b9f6938090c37b5092969ba1c67118e9114e8e6ef9d197251671444e839c/scipy-1.11.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ce7fff2e23ab2cc81ff452a9444c215c28e6305f396b2ba88343a567feec9660", size = 35963721 }, + { url = "https://files.pythonhosted.org/packages/c6/a1/357e4cd43af2748e1e0407ae0e9a5ea8aaaa6b702833c81be11670dcbad8/scipy-1.11.4-cp312-cp312-win_amd64.whl", hash = "sha256:36750b7733d960d7994888f0d148d31ea3017ac15eef664194b4ef68d36a4a97", size = 43730653 }, +] + +[[package]] +name = "setuptools" +version = "70.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/d8/10a70e86f6c28ae59f101a9de6d77bf70f147180fbf40c3af0f64080adc3/setuptools-70.3.0.tar.gz", hash = "sha256:f171bab1dfbc86b132997f26a119f6056a57950d058587841a0082e8830f9dc5", size = 2333112 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/15/88e46eb9387e905704b69849618e699dc2f54407d8953cc4ec4b8b46528d/setuptools-70.3.0-py3-none-any.whl", hash = "sha256:fe384da74336c398e0d956d1cae0669bc02eed936cdb1d49b57de1990dc11ffc", size = 931070 }, +] + +[[package]] +name = "six" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053 }, +] + +[[package]] +name = "sniffio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/50/d49c388cae4ec10e8109b1b833fd265511840706808576df3ada99ecb0ac/sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101", size = 17103 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/a0/5dba8ed157b0136607c7f2151db695885606968d1fae123dc3391e0cfdbf/sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384", size = 10165 }, +] + +[[package]] +name = "starlette" +version = "0.41.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/da/1fb4bdb72ae12b834becd7e1e7e47001d32f91ec0ce8d7bc1b618d9f0bd9/starlette-0.41.2.tar.gz", hash = "sha256:9834fd799d1a87fd346deb76158668cfa0b0d56f85caefe8268e2d97c3468b62", size = 2573867 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/43/f185bfd0ca1d213beb4293bed51d92254df23d8ceaf6c0e17146d508a776/starlette-0.41.2-py3-none-any.whl", hash = "sha256:fbc189474b4731cf30fcef52f18a8d070e3f3b46c6a04c97579e85e6ffca942d", size = 73259 }, +] + +[[package]] +name = "sympy" +version = "1.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/57/3485a1a3dff51bfd691962768b14310dae452431754bfc091250be50dd29/sympy-1.12.tar.gz", hash = "sha256:ebf595c8dac3e0fdc4152c51878b498396ec7f30e7a914d6071e674d49420fb8", size = 6722203 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/05/e6600db80270777c4a64238a98d442f0fd07cc8915be2a1c16da7f2b9e74/sympy-1.12-py3-none-any.whl", hash = "sha256:c3588cd4295d0c0f603d0f2ae780587e64e2efeedb3521e46b9bb1d08d184fa5", size = 5742435 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/12/fd4dea011af9d69e1cad05c75f3f7202cdcbeac9b712eea58ca779a72865/threadpoolctl-3.2.0-py3-none-any.whl", hash = "sha256:2b7818516e423bdaebb97c723f86a7c6b0a83d3f3b0970328d66f4d9104dc032", size = 15539 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/a4/569fc717831969cf48bced350bdaf070cdeab06918d179429899e144358d/tifffile-2023.12.9-py3-none-any.whl", hash = "sha256:9b066e4b1a900891ea42ffd33dab8ba34c537935618b9893ddef42d7d422692f", size = 223627 }, +] + +[[package]] +name = "tokenizers" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/41/c2be10975ca37f6ec40d7abd7e98a5213bb04f284b869c1a24e6504fd94d/tokenizers-0.21.0.tar.gz", hash = "sha256:ee0894bf311b75b0c03079f33859ae4b2334d675d4e93f5a4132e1eae2834fe4", size = 343021 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/5c/8b09607b37e996dc47e70d6a7b6f4bdd4e4d5ab22fe49d7374565c7fefaf/tokenizers-0.21.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:3c4c93eae637e7d2aaae3d376f06085164e1660f89304c0ab2b1d08a406636b2", size = 2647461 }, + { url = "https://files.pythonhosted.org/packages/22/7a/88e58bb297c22633ed1c9d16029316e5b5ac5ee44012164c2edede599a5e/tokenizers-0.21.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:f53ea537c925422a2e0e92a24cce96f6bc5046bbef24a1652a5edc8ba975f62e", size = 2563639 }, + { url = "https://files.pythonhosted.org/packages/f7/14/83429177c19364df27d22bc096d4c2e431e0ba43e56c525434f1f9b0fd00/tokenizers-0.21.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b177fb54c4702ef611de0c069d9169f0004233890e0c4c5bd5508ae05abf193", size = 2903304 }, + { url = "https://files.pythonhosted.org/packages/7e/db/3433eab42347e0dc5452d8fcc8da03f638c9accffefe5a7c78146666964a/tokenizers-0.21.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6b43779a269f4629bebb114e19c3fca0223296ae9fea8bb9a7a6c6fb0657ff8e", size = 2804378 }, + { url = "https://files.pythonhosted.org/packages/57/8b/7da5e6f89736c2ade02816b4733983fca1c226b0c42980b1ae9dc8fcf5cc/tokenizers-0.21.0-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9aeb255802be90acfd363626753fda0064a8df06031012fe7d52fd9a905eb00e", size = 3095488 }, + { url = "https://files.pythonhosted.org/packages/4d/f6/5ed6711093dc2c04a4e03f6461798b12669bc5a17c8be7cce1240e0b5ce8/tokenizers-0.21.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8b09dbeb7a8d73ee204a70f94fc06ea0f17dcf0844f16102b9f414f0b7463ba", size = 3121410 }, + { url = "https://files.pythonhosted.org/packages/81/42/07600892d48950c5e80505b81411044a2d969368cdc0d929b1c847bf6697/tokenizers-0.21.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:400832c0904f77ce87c40f1a8a27493071282f785724ae62144324f171377273", size = 3388821 }, + { url = "https://files.pythonhosted.org/packages/22/06/69d7ce374747edaf1695a4f61b83570d91cc8bbfc51ccfecf76f56ab4aac/tokenizers-0.21.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84ca973b3a96894d1707e189c14a774b701596d579ffc7e69debfc036a61a04", size = 3008868 }, + { url = "https://files.pythonhosted.org/packages/c8/69/54a0aee4d576045b49a0eb8bffdc495634309c823bf886042e6f46b80058/tokenizers-0.21.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:eb7202d231b273c34ec67767378cd04c767e967fda12d4a9e36208a34e2f137e", size = 8975831 }, + { url = "https://files.pythonhosted.org/packages/f7/f3/b776061e4f3ebf2905ba1a25d90380aafd10c02d406437a8ba22d1724d76/tokenizers-0.21.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:089d56db6782a73a27fd8abf3ba21779f5b85d4a9f35e3b493c7bbcbbf0d539b", size = 8920746 }, + { url = "https://files.pythonhosted.org/packages/d8/ee/ce83d5ec8b6844ad4c3ecfe3333d58ecc1adc61f0878b323a15355bcab24/tokenizers-0.21.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:c87ca3dc48b9b1222d984b6b7490355a6fdb411a2d810f6f05977258400ddb74", size = 9161814 }, + { url = "https://files.pythonhosted.org/packages/18/07/3e88e65c0ed28fa93aa0c4d264988428eef3df2764c3126dc83e243cb36f/tokenizers-0.21.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4145505a973116f91bc3ac45988a92e618a6f83eb458f49ea0790df94ee243ff", size = 9357138 }, + { url = "https://files.pythonhosted.org/packages/15/b0/dc4572ca61555fc482ebc933f26cb407c6aceb3dc19c301c68184f8cad03/tokenizers-0.21.0-cp39-abi3-win32.whl", hash = "sha256:eb1702c2f27d25d9dd5b389cc1f2f51813e99f8ca30d9e25348db6585a97e24a", size = 2202266 }, + { url = "https://files.pythonhosted.org/packages/44/69/d21eb253fa91622da25585d362a874fa4710be600f0ea9446d8d0217cec1/tokenizers-0.21.0-cp39-abi3-win_amd64.whl", hash = "sha256:87841da5a25a3a5f70c102de371db120f41873b854ba65e52bccd57df5a3780c", size = 2389192 }, +] + +[[package]] +name = "tomli" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/3f/d7af728f075fb08564c5949a9c95e44352e23dee646869fa104a3b2060a3/tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f", size = 15164 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", size = 12757 }, +] + +[[package]] +name = "tqdm" +version = "4.66.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/00/6a9b3aedb0b60a80995ade30f718f1a9902612f22a1aaf531c85a02987f7/tqdm-4.66.3.tar.gz", hash = "sha256:23097a41eba115ba99ecae40d06444c15d1c0c698d527a01c6c8bd1c5d0647e5", size = 169551 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/ad/7d47bbf2cae78ff79f29db0bed5016ec9c56b212a93fca624bb88b551a7c/tqdm-4.66.3-py3-none-any.whl", hash = "sha256:4f41d54107ff9a223dca80b53efe4fb654c67efaba7f47bada3ee9d50e05bd53", size = 78374 }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20241230" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/f9/4d566925bcf9396136c0a2e5dc7e230ff08d86fa011a69888dd184469d80/types_pyyaml-6.0.12.20241230.tar.gz", hash = "sha256:7f07622dbd34bb9c8b264fe860a17e0efcad00d50b5f27e93984909d9363498c", size = 17078 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/c1/48474fbead512b70ccdb4f81ba5eb4a58f69d100ba19f17c92c0c4f50ae6/types_PyYAML-6.0.12.20241230-py3-none-any.whl", hash = "sha256:fa4d32565219b68e6dee5f67534c722e53c00d1cfc09c435ef04d7353e1e96e6", size = 20029 }, +] + +[[package]] +name = "types-requests" +version = "2.32.0.20250306" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/1a/beaeff79ef9efd186566ba5f0d95b44ae21f6d31e9413bcfbef3489b6ae3/types_requests-2.32.0.20250306.tar.gz", hash = "sha256:0962352694ec5b2f95fda877ee60a159abdf84a0fc6fdace599f20acb41a03d1", size = 23012 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/26/645d89f56004aa0ba3b96fec27793e3c7e62b40982ee069e52568922b6db/types_requests-2.32.0.20250306-py3-none-any.whl", hash = "sha256:25f2cbb5c8710b2022f8bbee7b2b66f319ef14aeea2f35d80f18c9dbf3b60a0b", size = 20673 }, +] + +[[package]] +name = "types-setuptools" +version = "75.8.2.20250305" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4f/18/a996861f5225e7d533a8d8b6aa61bcc9183429a6b8bc93b850aa2e22974d/types_setuptools-75.8.2.20250305.tar.gz", hash = "sha256:a987269b49488f21961a1d99aa8d281b611625883def6392a93855b31544e405", size = 42609 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/5b/bb33f99239a6d54ed1d8220a088d96d2ccacac7abb317df0d68d2500f3be/types_setuptools-75.8.2.20250305-py3-none-any.whl", hash = "sha256:ba80953fd1f5f49e552285c024f75b5223096a38a5138a54d18ddd3fa8f6a2d4", size = 63727 }, +] + +[[package]] +name = "types-simplejson" +version = "3.20.0.20250218" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/b0/a2e4a46af6b4822b1a281b9ddcaba2451f7ceec0b114b09b1ee9d45ae90b/types_simplejson-3.20.0.20250218.tar.gz", hash = "sha256:5c5c46f67690f211d628d5182546b43ea9ff03936efd759549ac1795b213e3e7", size = 9824 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/8f/4eb139636659bd906483809a3be7e280c56d88eb9023ea1cf5b9e8acd82e/types_simplejson-3.20.0.20250218-py3-none-any.whl", hash = "sha256:dbe00ea8497f8ba2f91dd9654d7064613cc09df545e5a008e8240fc6a407e98f", size = 10333 }, +] + +[[package]] +name = "types-ujson" +version = "5.10.0.20240515" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/49/abb4bcb9f2258f785edbf236b517c3e7ba8a503a8cbce6b5895930586cc0/types-ujson-5.10.0.20240515.tar.gz", hash = "sha256:ceae7127f0dafe4af5dd0ecf98ee13e9d75951ef963b5c5a9b7ea92e0d71f0d7", size = 3571 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/1f/9d018cee3d09ab44a5211f0b5ed9b0422ad9a8c226bf3967f5884498d8f0/types_ujson-5.10.0.20240515-py3-none-any.whl", hash = "sha256:02bafc36b3a93d2511757a64ff88bd505e0a57fba08183a9150fbcfcb2015310", size = 2757 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] + +[[package]] +name = "urllib3" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/dd/a6b232f449e1bc71802a5b7950dc3675d32c6dbc2a1bd6d71f065551adb6/urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54", size = 263900 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/94/c31f58c7a7f470d5665935262ebd7455c7e4c7782eb525658d3dbf4b9403/urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3", size = 104579 }, +] + +[[package]] +name = "uvicorn" +version = "0.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.19.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/16/728cc5dde368e6eddb299c5aec4d10eaf25335a5af04e8c0abd68e2e9d32/uvloop-0.19.0.tar.gz", hash = "sha256:0246f4fd1bf2bf702e06b0d45ee91677ee5c31242f39aab4ea6fe0c51aedd0fd", size = 2318492 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/c2/27bf858a576b1fa35b5c2c2029c8cec424a8789e87545ed2f25466d1f21d/uvloop-0.19.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:de4313d7f575474c8f5a12e163f6d89c0a878bc49219641d49e6f1444369a90e", size = 1443484 }, + { url = "https://files.pythonhosted.org/packages/4e/35/05b6064b93f4113412d1fd92bdcb6018607e78ae94d1712e63e533f9b2fa/uvloop-0.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5588bd21cf1fcf06bded085f37e43ce0e00424197e7c10e77afd4bbefffef428", size = 793850 }, + { url = "https://files.pythonhosted.org/packages/aa/56/b62ab4e10458ce96bb30c98d327c127f989d3bb4ef899e4c410c739f7ef6/uvloop-0.19.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b1fd71c3843327f3bbc3237bedcdb6504fd50368ab3e04d0410e52ec293f5b8", size = 3418601 }, + { url = "https://files.pythonhosted.org/packages/ab/ed/12729fba5e3b7e02ee70b3ea230b88e60a50375cf63300db22607694d2f0/uvloop-0.19.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a05128d315e2912791de6088c34136bfcdd0c7cbc1cf85fd6fd1bb321b7c849", size = 3416731 }, + { url = "https://files.pythonhosted.org/packages/a2/23/80381a2d728d2a0c36e2eef202f5b77428990004d8fbdd3865558ff49fa5/uvloop-0.19.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:cd81bdc2b8219cb4b2556eea39d2e36bfa375a2dd021404f90a62e44efaaf957", size = 4128572 }, + { url = "https://files.pythonhosted.org/packages/6b/23/1ee41a15e1ad15182e2bd12cbfd37bcb6802f01d6bbcaddf6ca136cbb308/uvloop-0.19.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5f17766fb6da94135526273080f3455a112f82570b2ee5daa64d682387fe0dcd", size = 4129235 }, + { url = "https://files.pythonhosted.org/packages/41/2a/608ad69f27f51280098abee440c33e921d3ad203e2c86f7262e241e49c99/uvloop-0.19.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4ce6b0af8f2729a02a5d1575feacb2a94fc7b2e983868b009d51c9a9d2149bef", size = 1357681 }, + { url = "https://files.pythonhosted.org/packages/13/00/d0923d66d80c8717983493a4d7af747ce47f1c2147d82df057a846ba6bff/uvloop-0.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:31e672bb38b45abc4f26e273be83b72a0d28d074d5b370fc4dcf4c4eb15417d2", size = 746421 }, + { url = "https://files.pythonhosted.org/packages/1f/c7/e494c367b0c6e6453f9bed5a78548f5b2ff49add36302cd915a91d347d88/uvloop-0.19.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:570fc0ed613883d8d30ee40397b79207eedd2624891692471808a95069a007c1", size = 3481000 }, + { url = "https://files.pythonhosted.org/packages/86/cc/1829b3f740e4cb1baefff8240a1c6fc8db9e3caac7b93169aec7d4386069/uvloop-0.19.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5138821e40b0c3e6c9478643b4660bd44372ae1e16a322b8fc07478f92684e24", size = 3476361 }, + { url = "https://files.pythonhosted.org/packages/7a/4c/ca87e8f5a30629ffa2038c20907c8ab455c5859ff10e810227b76e60d927/uvloop-0.19.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:91ab01c6cd00e39cde50173ba4ec68a1e578fee9279ba64f5221810a9e786533", size = 4169571 }, + { url = "https://files.pythonhosted.org/packages/d2/a9/f947a00c47b1c87c937cac2423243a41ba08f0fb76d04eb0d1d170606e0a/uvloop-0.19.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:47bf3e9312f63684efe283f7342afb414eea4d3011542155c7e625cd799c3b12", size = 4170459 }, + { url = "https://files.pythonhosted.org/packages/85/57/6736733bb0e86a4b5380d04082463b289c0baecaa205934ba81e8a1d5ea4/uvloop-0.19.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:da8435a3bd498419ee8c13c34b89b5005130a476bda1d6ca8cfdde3de35cd650", size = 1355376 }, + { url = "https://files.pythonhosted.org/packages/eb/0c/51339463da912ed34b48d470538d98a91660749b2db56902f23db9b42fdd/uvloop-0.19.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:02506dc23a5d90e04d4f65c7791e65cf44bd91b37f24cfc3ef6cf2aff05dc7ec", size = 745031 }, + { url = "https://files.pythonhosted.org/packages/e6/fc/f0daaf19f5b2116a2d26eb9f98c4a45084aea87bf03c33bcca7aa1ff36e5/uvloop-0.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2693049be9d36fef81741fddb3f441673ba12a34a704e7b4361efb75cf30befc", size = 4077630 }, + { url = "https://files.pythonhosted.org/packages/fd/96/fdc318ffe82ae567592b213ec2fcd8ecedd927b5da068cf84d56b28c51a4/uvloop-0.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7010271303961c6f0fe37731004335401eb9075a12680738731e9c92ddd96ad6", size = 4159957 }, + { url = "https://files.pythonhosted.org/packages/71/bc/092068ae7fc16dcf20f3e389126ba7800cee75ffba83f78bf1d167aee3cd/uvloop-0.19.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5daa304d2161d2918fa9a17d5635099a2f78ae5b5960e742b2fcfbb7aefaa593", size = 4014951 }, + { url = "https://files.pythonhosted.org/packages/a6/f2/6ce1e73933eb038c89f929e26042e64b2cb8d4453410153eed14918ca9a8/uvloop-0.19.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7207272c9520203fea9b93843bb775d03e1cf88a80a936ce760f60bb5add92f3", size = 4100911 }, +] + +[[package]] +name = "watchfiles" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/79/0ee412e1228aaf6f9568aa180b43cb482472de52560fbd7c283c786534af/watchfiles-0.21.0.tar.gz", hash = "sha256:c76c635fabf542bb78524905718c39f736a98e5ab25b23ec6d4abede1a85a6a3", size = 37098 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/85/ea2a035b7d86bf0a29ee1c32bc2df8ad4da77e6602806e679d9735ff28cb/watchfiles-0.21.0-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:27b4035013f1ea49c6c0b42d983133b136637a527e48c132d368eb19bf1ac6aa", size = 428182 }, + { url = "https://files.pythonhosted.org/packages/b5/e5/240e5eb3ff0ee3da3b028ac5be2019c407bdd0f3fdb02bd75fdf3bd10aff/watchfiles-0.21.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c81818595eff6e92535ff32825f31c116f867f64ff8cdf6562cd1d6b2e1e8f3e", size = 418275 }, + { url = "https://files.pythonhosted.org/packages/5b/79/ecd0dfb04443a1900cd3952d7ea6493bf655c2db9a0d3736a5d98a15da39/watchfiles-0.21.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6c107ea3cf2bd07199d66f156e3ea756d1b84dfd43b542b2d870b77868c98c03", size = 1379785 }, + { url = "https://files.pythonhosted.org/packages/41/0e/3333b986b1889bb71f0e44b3fac0591824a679619b8b8ddd70ff8858edc4/watchfiles-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d9ac347653ebd95839a7c607608703b20bc07e577e870d824fa4801bc1cb124", size = 1349374 }, + { url = "https://files.pythonhosted.org/packages/18/c4/ad5ad16cad900a29aaa792e0ed121ff70d76f74062b051661090d88c6dfd/watchfiles-0.21.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5eb86c6acb498208e7663ca22dbe68ca2cf42ab5bf1c776670a50919a56e64ab", size = 1348033 }, + { url = "https://files.pythonhosted.org/packages/4e/d2/769254ff04ba88ceb179a6e892606ac4da17338eb010e85ca7a9c3339234/watchfiles-0.21.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f564bf68404144ea6b87a78a3f910cc8de216c6b12a4cf0b27718bf4ec38d303", size = 1464393 }, + { url = "https://files.pythonhosted.org/packages/14/d0/662800e778ca20e7664dd5df57751aa79ef18b6abb92224b03c8c2e852a6/watchfiles-0.21.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d0f32ebfaa9c6011f8454994f86108c2eb9c79b8b7de00b36d558cadcedaa3d", size = 1542953 }, + { url = "https://files.pythonhosted.org/packages/f7/4b/b90dcdc3bbaf3bb2db733e1beea2d01566b601c15fcf8e71dfcc8686c097/watchfiles-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6d45d9b699ecbac6c7bd8e0a2609767491540403610962968d258fd6405c17c", size = 1346961 }, + { url = "https://files.pythonhosted.org/packages/92/ff/75cc1b30c5abcad13a2a72e75625ec619c7a393028a111d7d24dba578d5e/watchfiles-0.21.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:aff06b2cac3ef4616e26ba17a9c250c1fe9dd8a5d907d0193f84c499b1b6e6a9", size = 1464393 }, + { url = "https://files.pythonhosted.org/packages/9a/65/12cbeb363bf220482a559c48107edfd87f09248f55e1ac315a36c2098a0f/watchfiles-0.21.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d9792dff410f266051025ecfaa927078b94cc7478954b06796a9756ccc7e14a9", size = 1463409 }, + { url = "https://files.pythonhosted.org/packages/f2/08/92e28867c66f0d9638bb131feca739057efc48dbcd391fd7f0a55507e470/watchfiles-0.21.0-cp310-none-win32.whl", hash = "sha256:214cee7f9e09150d4fb42e24919a1e74d8c9b8a9306ed1474ecaddcd5479c293", size = 268101 }, + { url = "https://files.pythonhosted.org/packages/4b/ea/80527adf1ad51488a96fc201715730af5879f4dfeccb5e2069ff82d890d4/watchfiles-0.21.0-cp310-none-win_amd64.whl", hash = "sha256:1ad7247d79f9f55bb25ab1778fd47f32d70cf36053941f07de0b7c4e96b5d235", size = 279675 }, + { url = "https://files.pythonhosted.org/packages/57/b9/2667286003dd305b81d3a3aa824d3dfc63dacbf2a96faae09e72d953c430/watchfiles-0.21.0-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:668c265d90de8ae914f860d3eeb164534ba2e836811f91fecc7050416ee70aa7", size = 428210 }, + { url = "https://files.pythonhosted.org/packages/a3/87/6793ac60d2e20c9c1883aec7431c2e7b501ee44a839f6da1b747c13baa23/watchfiles-0.21.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a23092a992e61c3a6a70f350a56db7197242f3490da9c87b500f389b2d01eef", size = 418196 }, + { url = "https://files.pythonhosted.org/packages/5d/12/e1d1d220c5b99196eea38c9a878964f30a2b55ec9d72fd713191725b35e8/watchfiles-0.21.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e7941bbcfdded9c26b0bf720cb7e6fd803d95a55d2c14b4bd1f6a2772230c586", size = 1380287 }, + { url = "https://files.pythonhosted.org/packages/0e/cf/126f0a8683f326d190c3539a769e45e747a80a5fcbf797de82e738c946ae/watchfiles-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11cd0c3100e2233e9c53106265da31d574355c288e15259c0d40a4405cbae317", size = 1349653 }, + { url = "https://files.pythonhosted.org/packages/20/6e/6cffd795ec65dbc82f15d95b73d3042c1ddaffc4dd25f6c8240bfcf0640f/watchfiles-0.21.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78f30cbe8b2ce770160d3c08cff01b2ae9306fe66ce899b73f0409dc1846c1b", size = 1348844 }, + { url = "https://files.pythonhosted.org/packages/d5/2a/f9633279d8937ad84c532997405dd106fa6100e8d2b83e364f1c87561f96/watchfiles-0.21.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6674b00b9756b0af620aa2a3346b01f8e2a3dc729d25617e1b89cf6af4a54eb1", size = 1464343 }, + { url = "https://files.pythonhosted.org/packages/d7/49/9b2199bbf3c89e7c8dd795fced9dac29f201be8a28a5df0c8ff625737df6/watchfiles-0.21.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fd7ac678b92b29ba630d8c842d8ad6c555abda1b9ef044d6cc092dacbfc9719d", size = 1542858 }, + { url = "https://files.pythonhosted.org/packages/35/e0/e8a9c1fe30e98c5b3507ad381abc4d9ee2c3b9c0ae62ffe9c164a5838186/watchfiles-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c873345680c1b87f1e09e0eaf8cf6c891b9851d8b4d3645e7efe2ec20a20cc7", size = 1347464 }, + { url = "https://files.pythonhosted.org/packages/ba/66/873739dd7defdfaee4b880114de9463fae18ba13ae2ddd784806b0ee33b6/watchfiles-0.21.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:49f56e6ecc2503e7dbe233fa328b2be1a7797d31548e7a193237dcdf1ad0eee0", size = 1464343 }, + { url = "https://files.pythonhosted.org/packages/bd/51/d7539aa258d8f0e5d7b870af8b9b8964b4f88a1e4517eeb8a2efb838e9b3/watchfiles-0.21.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:02d91cbac553a3ad141db016e3350b03184deaafeba09b9d6439826ee594b365", size = 1463338 }, + { url = "https://files.pythonhosted.org/packages/ee/92/219c539a2a93b6870fa7b84eace946983126b20a7e15c6c034d8d0472682/watchfiles-0.21.0-cp311-none-win32.whl", hash = "sha256:ebe684d7d26239e23d102a2bad2a358dedf18e462e8808778703427d1f584400", size = 267658 }, + { url = "https://files.pythonhosted.org/packages/f3/dc/2a8a447b783f5059c4bf7a6bad8fe59375a5a9ce872774763b25c21c2860/watchfiles-0.21.0-cp311-none-win_amd64.whl", hash = "sha256:4566006aa44cb0d21b8ab53baf4b9c667a0ed23efe4aaad8c227bfba0bf15cbe", size = 280113 }, + { url = "https://files.pythonhosted.org/packages/22/15/e4085181cf0210a6ec6eb29fee0c6088de867ee33d81555076a4a2726e8b/watchfiles-0.21.0-cp311-none-win_arm64.whl", hash = "sha256:c550a56bf209a3d987d5a975cdf2063b3389a5d16caf29db4bdddeae49f22078", size = 268688 }, + { url = "https://files.pythonhosted.org/packages/a1/fd/2f009eb17809afd32a143b442856628585c9ce3a9c6d5c1841e44e35a16c/watchfiles-0.21.0-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:51ddac60b96a42c15d24fbdc7a4bfcd02b5a29c047b7f8bf63d3f6f5a860949a", size = 426902 }, + { url = "https://files.pythonhosted.org/packages/e0/62/a2605f212a136e06f2d056ee7491ede9935ba0f1d5ceafd1f7da2a0c8625/watchfiles-0.21.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:511f0b034120cd1989932bf1e9081aa9fb00f1f949fbd2d9cab6264916ae89b1", size = 417300 }, + { url = "https://files.pythonhosted.org/packages/69/0e/29f158fa22eb2cc1f188b5ec20fb5c0a64eb801e3901ad5b7ad546cbaed0/watchfiles-0.21.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:cfb92d49dbb95ec7a07511bc9efb0faff8fe24ef3805662b8d6808ba8409a71a", size = 1378126 }, + { url = "https://files.pythonhosted.org/packages/e8/f3/c67865cb5a174201c52d34e870cc7956b8408ee83ce9a02909d6a2a93a14/watchfiles-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f92944efc564867bbf841c823c8b71bb0be75e06b8ce45c084b46411475a915", size = 1348275 }, + { url = "https://files.pythonhosted.org/packages/d7/eb/b6f1184d1c7b9670f5bd1e184e4c221ecf25fd817cf2fcac6adc387882b5/watchfiles-0.21.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:642d66b75eda909fd1112d35c53816d59789a4b38c141a96d62f50a3ef9b3360", size = 1347255 }, + { url = "https://files.pythonhosted.org/packages/c8/27/e534e4b3fe739f4bf8bd5dc4c26cbc5d3baa427125d8ef78a6556acd6ff4/watchfiles-0.21.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d23bcd6c8eaa6324fe109d8cac01b41fe9a54b8c498af9ce464c1aeeb99903d6", size = 1462845 }, + { url = "https://files.pythonhosted.org/packages/b0/ba/a0d1c1c55f75e7e47c8f79f2314f7ec670b5177596f6d27764aecc7048cd/watchfiles-0.21.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18d5b4da8cf3e41895b34e8c37d13c9ed294954907929aacd95153508d5d89d7", size = 1528957 }, + { url = "https://files.pythonhosted.org/packages/1c/3a/4e38518c4dff58090c01fc8cc051fa08ac9ae00b361c855075809b0058ce/watchfiles-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b8d1eae0f65441963d805f766c7e9cd092f91e0c600c820c764a4ff71a0764c", size = 1345542 }, + { url = "https://files.pythonhosted.org/packages/9f/b7/783097f8137a710d5cd9ccbfcd92e4b453d38dab05cfcb5dbd2c587752e5/watchfiles-0.21.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1fd9a5205139f3c6bb60d11f6072e0552f0a20b712c85f43d42342d162be1235", size = 1462238 }, + { url = "https://files.pythonhosted.org/packages/6b/4c/b741eb38f2c408ae9c5a25235f6506b1dda43486ae0fdb4c462ef75bce11/watchfiles-0.21.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a1e3014a625bcf107fbf38eece0e47fa0190e52e45dc6eee5a8265ddc6dc5ea7", size = 1462406 }, + { url = "https://files.pythonhosted.org/packages/77/e4/8d2b3c67364671b0e1c0ce383895a5415f45ecb3e8586982deff4a8e85c9/watchfiles-0.21.0-cp312-none-win32.whl", hash = "sha256:9d09869f2c5a6f2d9df50ce3064b3391d3ecb6dced708ad64467b9e4f2c9bef3", size = 266789 }, + { url = "https://files.pythonhosted.org/packages/da/f2/6b1de38aeb21eb9dac1ae6a1ee4521566e79690117032036c737cfab52fa/watchfiles-0.21.0-cp312-none-win_amd64.whl", hash = "sha256:18722b50783b5e30a18a8a5db3006bab146d2b705c92eb9a94f78c72beb94094", size = 280292 }, + { url = "https://files.pythonhosted.org/packages/5a/a5/7aba9435beb863c2490bae3173a45f42044ac7a48155d3dd42ab49cfae45/watchfiles-0.21.0-cp312-none-win_arm64.whl", hash = "sha256:a3b9bec9579a15fb3ca2d9878deae789df72f2b0fdaf90ad49ee389cad5edab6", size = 268026 }, + { url = "https://files.pythonhosted.org/packages/62/66/7463ceb43eabc6deaa795c7969ff4d4fd938de54e655035483dfd1e97c84/watchfiles-0.21.0-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:ab03a90b305d2588e8352168e8c5a1520b721d2d367f31e9332c4235b30b8994", size = 429092 }, + { url = "https://files.pythonhosted.org/packages/fe/a3/42686af3a089f34aba35c39abac852869661938dae7025c1a0580dfe0fbf/watchfiles-0.21.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:927c589500f9f41e370b0125c12ac9e7d3a2fd166b89e9ee2828b3dda20bfe6f", size = 419188 }, + { url = "https://files.pythonhosted.org/packages/37/17/4825999346f15d650f4c69093efa64fb040fbff4f706a20e8c4745f64070/watchfiles-0.21.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bd467213195e76f838caf2c28cd65e58302d0254e636e7c0fca81efa4a2e62c", size = 1350366 }, + { url = "https://files.pythonhosted.org/packages/70/76/8d124e14cf51af4d6bba926c7473f253c6efd1539ba62577f079a2d71537/watchfiles-0.21.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02b73130687bc3f6bb79d8a170959042eb56eb3a42df3671c79b428cd73f17cc", size = 1346270 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, +] + +[[package]] +name = "websockets" +version = "12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/62/7a7874b7285413c954a4cca3c11fd851f11b2fe5b4ae2d9bee4f6d9bdb10/websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b", size = 104994 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/b9/360b86ded0920a93bff0db4e4b0aa31370b0208ca240b2e98d62aad8d082/websockets-12.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d554236b2a2006e0ce16315c16eaa0d628dab009c33b63ea03f41c6107958374", size = 124025 }, + { url = "https://files.pythonhosted.org/packages/bb/d3/1eca0d8fb6f0665c96f0dc7c0d0ec8aa1a425e8c003e0c18e1451f65d177/websockets-12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2d225bb6886591b1746b17c0573e29804619c8f755b5598d875bb4235ea639be", size = 121261 }, + { url = "https://files.pythonhosted.org/packages/4e/e1/f6c3ecf7f1bfd9209e13949db027d7fdea2faf090c69b5f2d17d1d796d96/websockets-12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eb809e816916a3b210bed3c82fb88eaf16e8afcf9c115ebb2bacede1797d2547", size = 121328 }, + { url = "https://files.pythonhosted.org/packages/74/4d/f88eeceb23cb587c4aeca779e3f356cf54817af2368cb7f2bd41f93c8360/websockets-12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c588f6abc13f78a67044c6b1273a99e1cf31038ad51815b3b016ce699f0d75c2", size = 130925 }, + { url = "https://files.pythonhosted.org/packages/16/17/f63d9ee6ffd9afbeea021d5950d6e8db84cd4aead306c6c2ca523805699e/websockets-12.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5aa9348186d79a5f232115ed3fa9020eab66d6c3437d72f9d2c8ac0c6858c558", size = 129930 }, + { url = "https://files.pythonhosted.org/packages/9a/12/c7a7504f5bf74d6ee0533f6fc7d30d8f4b79420ab179d1df2484b07602eb/websockets-12.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6350b14a40c95ddd53e775dbdbbbc59b124a5c8ecd6fbb09c2e52029f7a9f480", size = 130245 }, + { url = "https://files.pythonhosted.org/packages/e4/6a/3600c7771eb31116d2e77383d7345618b37bb93709d041e328c08e2a8eb3/websockets-12.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:70ec754cc2a769bcd218ed8d7209055667b30860ffecb8633a834dde27d6307c", size = 134966 }, + { url = "https://files.pythonhosted.org/packages/22/26/df77c4b7538caebb78c9b97f43169ef742a4f445e032a5ea1aaef88f8f46/websockets-12.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6e96f5ed1b83a8ddb07909b45bd94833b0710f738115751cdaa9da1fb0cb66e8", size = 134196 }, + { url = "https://files.pythonhosted.org/packages/e5/18/18ce9a4a08203c8d0d3d561e3ea4f453daf32f099601fc831e60c8a9b0f2/websockets-12.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4d87be612cbef86f994178d5186add3d94e9f31cc3cb499a0482b866ec477603", size = 134822 }, + { url = "https://files.pythonhosted.org/packages/45/51/1f823a341fc20a880e67ae62f6c38c4880a24a4b60fbe544a38f516f39a1/websockets-12.0-cp310-cp310-win32.whl", hash = "sha256:befe90632d66caaf72e8b2ed4d7f02b348913813c8b0a32fae1cc5fe3730902f", size = 124454 }, + { url = "https://files.pythonhosted.org/packages/41/b0/5ec054cfcf23adfc88d39359b85e81d043af8a141e3ac8ce40f45a5ce5f4/websockets-12.0-cp310-cp310-win_amd64.whl", hash = "sha256:363f57ca8bc8576195d0540c648aa58ac18cf85b76ad5202b9f976918f4219cf", size = 124974 }, + { url = "https://files.pythonhosted.org/packages/02/73/9c1e168a2e7fdf26841dc98f5f5502e91dea47428da7690a08101f616169/websockets-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5d873c7de42dea355d73f170be0f23788cf3fa9f7bed718fd2830eefedce01b4", size = 124047 }, + { url = "https://files.pythonhosted.org/packages/e4/2d/9a683359ad2ed11b2303a7a94800db19c61d33fa3bde271df09e99936022/websockets-12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3f61726cae9f65b872502ff3c1496abc93ffbe31b278455c418492016e2afc8f", size = 121282 }, + { url = "https://files.pythonhosted.org/packages/95/aa/75fa3b893142d6d98a48cb461169bd268141f2da8bfca97392d6462a02eb/websockets-12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed2fcf7a07334c77fc8a230755c2209223a7cc44fc27597729b8ef5425aa61a3", size = 121325 }, + { url = "https://files.pythonhosted.org/packages/6e/a4/51a25e591d645df71ee0dc3a2c880b28e5514c00ce752f98a40a87abcd1e/websockets-12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e332c210b14b57904869ca9f9bf4ca32f5427a03eeb625da9b616c85a3a506c", size = 131502 }, + { url = "https://files.pythonhosted.org/packages/cd/ea/0ceeea4f5b87398fe2d9f5bcecfa00a1bcd542e2bfcac2f2e5dd612c4e9e/websockets-12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5693ef74233122f8ebab026817b1b37fe25c411ecfca084b29bc7d6efc548f45", size = 130491 }, + { url = "https://files.pythonhosted.org/packages/e3/05/f52a60b66d9faf07a4f7d71dc056bffafe36a7e98c4eb5b78f04fe6e4e85/websockets-12.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e9e7db18b4539a29cc5ad8c8b252738a30e2b13f033c2d6e9d0549b45841c04", size = 130872 }, + { url = "https://files.pythonhosted.org/packages/ac/4e/c7361b2d7b964c40fea924d64881145164961fcd6c90b88b7e3ab2c4f431/websockets-12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6e2df67b8014767d0f785baa98393725739287684b9f8d8a1001eb2839031447", size = 136318 }, + { url = "https://files.pythonhosted.org/packages/0a/31/337bf35ae5faeaf364c9cddec66681cdf51dc4414ee7a20f92a18e57880f/websockets-12.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bea88d71630c5900690fcb03161ab18f8f244805c59e2e0dc4ffadae0a7ee0ca", size = 135594 }, + { url = "https://files.pythonhosted.org/packages/95/aa/1ac767825c96f9d7e43c4c95683757d4ef28cf11fa47a69aca42428d3e3a/websockets-12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dff6cdf35e31d1315790149fee351f9e52978130cef6c87c4b6c9b3baf78bc53", size = 136191 }, + { url = "https://files.pythonhosted.org/packages/28/4b/344ec5cfeb6bc417da097f8253607c3aed11d9a305fb58346f506bf556d8/websockets-12.0-cp311-cp311-win32.whl", hash = "sha256:3e3aa8c468af01d70332a382350ee95f6986db479ce7af14d5e81ec52aa2b402", size = 124453 }, + { url = "https://files.pythonhosted.org/packages/d1/40/6b169cd1957476374f51f4486a3e85003149e62a14e6b78a958c2222337a/websockets-12.0-cp311-cp311-win_amd64.whl", hash = "sha256:25eb766c8ad27da0f79420b2af4b85d29914ba0edf69f547cc4f06ca6f1d403b", size = 124971 }, + { url = "https://files.pythonhosted.org/packages/a9/6d/23cc898647c8a614a0d9ca703695dd04322fb5135096a20c2684b7c852b6/websockets-12.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0e6e2711d5a8e6e482cacb927a49a3d432345dfe7dea8ace7b5790df5932e4df", size = 124061 }, + { url = "https://files.pythonhosted.org/packages/39/34/364f30fdf1a375e4002a26ee3061138d1571dfda6421126127d379d13930/websockets-12.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:dbcf72a37f0b3316e993e13ecf32f10c0e1259c28ffd0a85cee26e8549595fbc", size = 121296 }, + { url = "https://files.pythonhosted.org/packages/2e/00/96ae1c9dcb3bc316ef683f2febd8c97dde9f254dc36c3afc65c7645f734c/websockets-12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12743ab88ab2af1d17dd4acb4645677cb7063ef4db93abffbf164218a5d54c6b", size = 121326 }, + { url = "https://files.pythonhosted.org/packages/af/f1/bba1e64430685dd456c1a1fd6b0c791ae33104967b928aefeff261761e8d/websockets-12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b645f491f3c48d3f8a00d1fce07445fab7347fec54a3e65f0725d730d5b99cb", size = 131807 }, + { url = "https://files.pythonhosted.org/packages/62/3b/98ee269712f37d892b93852ce07b3e6d7653160ca4c0d4f8c8663f8021f8/websockets-12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9893d1aa45a7f8b3bc4510f6ccf8db8c3b62120917af15e3de247f0780294b92", size = 130751 }, + { url = "https://files.pythonhosted.org/packages/f1/00/d6f01ca2b191f8b0808e4132ccd2e7691f0453cbd7d0f72330eb97453c3a/websockets-12.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f38a7b376117ef7aff996e737583172bdf535932c9ca021746573bce40165ed", size = 131176 }, + { url = "https://files.pythonhosted.org/packages/af/9c/703ff3cd8109dcdee6152bae055d852ebaa7750117760ded697ab836cbcf/websockets-12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f764ba54e33daf20e167915edc443b6f88956f37fb606449b4a5b10ba42235a5", size = 136246 }, + { url = "https://files.pythonhosted.org/packages/0b/a5/1a38fb85a456b9dc874ec984f3ff34f6550eafd17a3da28753cd3c1628e8/websockets-12.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:1e4b3f8ea6a9cfa8be8484c9221ec0257508e3a1ec43c36acdefb2a9c3b00aa2", size = 135466 }, + { url = "https://files.pythonhosted.org/packages/3c/98/1261f289dff7e65a38d59d2f591de6ed0a2580b729aebddec033c4d10881/websockets-12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9fdf06fd06c32205a07e47328ab49c40fc1407cdec801d698a7c41167ea45113", size = 136083 }, + { url = "https://files.pythonhosted.org/packages/a9/1c/f68769fba63ccb9c13fe0a25b616bd5aebeef1c7ddebc2ccc32462fb784d/websockets-12.0-cp312-cp312-win32.whl", hash = "sha256:baa386875b70cbd81798fa9f71be689c1bf484f65fd6fb08d051a0ee4e79924d", size = 124460 }, + { url = "https://files.pythonhosted.org/packages/20/52/8915f51f9aaef4e4361c89dd6cf69f72a0159f14e0d25026c81b6ad22525/websockets-12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae0a5da8f35a5be197f328d4727dbcfafa53d1824fac3d96cdd3a642fe09394f", size = 124985 }, + { url = "https://files.pythonhosted.org/packages/43/8b/554a8a8bb6da9dd1ce04c44125e2192af7b7beebf6e3dbfa5d0e285cc20f/websockets-12.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:248d8e2446e13c1d4326e0a6a4e9629cb13a11195051a73acf414812700badbd", size = 121110 }, + { url = "https://files.pythonhosted.org/packages/b0/8e/58b8812940d746ad74d395fb069497255cb5ef50748dfab1e8b386b1f339/websockets-12.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44069528d45a933997a6fef143030d8ca8042f0dfaad753e2906398290e2870", size = 123216 }, + { url = "https://files.pythonhosted.org/packages/81/ee/272cb67ace1786ce6d9f39d47b3c55b335e8b75dd1972a7967aad39178b6/websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4e37d36f0d19f0a4413d3e18c0d03d0c268ada2061868c1e6f5ab1a6d575077", size = 122821 }, + { url = "https://files.pythonhosted.org/packages/a8/03/387fc902b397729df166763e336f4e5cec09fe7b9d60f442542c94a21be1/websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d829f975fc2e527a3ef2f9c8f25e553eb7bc779c6665e8e1d52aa22800bb38b", size = 122768 }, + { url = "https://files.pythonhosted.org/packages/50/f0/5939fbc9bc1979d79a774ce5b7c4b33c0cefe99af22fb70f7462d0919640/websockets-12.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2c71bd45a777433dd9113847af751aae36e448bc6b8c361a566cb043eda6ec30", size = 125009 }, + { url = "https://files.pythonhosted.org/packages/79/4d/9cc401e7b07e80532ebc8c8e993f42541534da9e9249c59ee0139dcb0352/websockets-12.0-py3-none-any.whl", hash = "sha256:dc284bbc8d7c78a6c69e0c7325ab46ee5e40bb4d50e494d8131a07ef47500e9e", size = 118370 }, +] + +[[package]] +name = "werkzeug" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/51/2e0fc149e7a810d300422ab543f87f2bcf64d985eb6f1228c4efd6e4f8d4/werkzeug-3.0.3.tar.gz", hash = "sha256:097e5bfda9f0aba8da6b8545146def481d06aa7d3266e7448e2cccf67dd8bd18", size = 803342 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/6e/e792999e816d19d7fcbfa94c730936750036d65656a76a5a688b57a656c4/werkzeug-3.0.3-py3-none-any.whl", hash = "sha256:fc9645dc43e03e4d630d23143a04a7f947a9a3b5727cd535fdfe155a17cc48c8", size = 227274 }, +] + +[[package]] +name = "zope-event" +version = "5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/c2/427f1867bb96555d1d34342f1dd97f8c420966ab564d58d18469a1db8736/zope.event-5.0.tar.gz", hash = "sha256:bac440d8d9891b4068e2b5a2c5e2c9765a9df762944bda6955f96bb9b91e67cd", size = 17350 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/42/f8dbc2b9ad59e927940325a22d6d3931d630c3644dae7e2369ef5d9ba230/zope.event-5.0-py3-none-any.whl", hash = "sha256:2832e95014f4db26c47a13fdaef84cef2f4df37e66b59d8f1f4a8f319a632c26", size = 6824 }, +] + +[[package]] +name = "zope-interface" +version = "6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/03/6b85c1df2dca1b9acca38b423d1e226d8ffdf30ebd78bcb398c511de8b54/zope.interface-6.1.tar.gz", hash = "sha256:2fdc7ccbd6eb6b7df5353012fbed6c3c5d04ceaca0038f75e601060e95345309", size = 293914 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/ec/c1e7ce928dc10bfe02c6da7e964342d941aaf168f96f8084636167ea50d2/zope.interface-6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:43b576c34ef0c1f5a4981163b551a8781896f2a37f71b8655fd20b5af0386abb", size = 202417 }, + { url = "https://files.pythonhosted.org/packages/f7/0b/12f269ad049fc40a7a3ab85445d7855b6bc6f1e774c5ca9dd6f5c32becb3/zope.interface-6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:67be3ca75012c6e9b109860820a8b6c9a84bfb036fbd1076246b98e56951ca92", size = 202528 }, + { url = "https://files.pythonhosted.org/packages/7f/85/3a35144509eb4a5a2208b48ae8d116a969d67de62cc6513d85602144d9cd/zope.interface-6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b9bc671626281f6045ad61d93a60f52fd5e8209b1610972cf0ef1bbe6d808e3", size = 247532 }, + { url = "https://files.pythonhosted.org/packages/50/d6/6176aaa1f6588378f5a5a4a9c6ad50a36824e902b2f844ca8de7f1b0c4a7/zope.interface-6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bbe81def9cf3e46f16ce01d9bfd8bea595e06505e51b7baf45115c77352675fd", size = 241703 }, + { url = "https://files.pythonhosted.org/packages/4f/20/94d4f221989b4bbdd09004b2afb329958e776b7015b7ea8bc915327e195a/zope.interface-6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dc998f6de015723196a904045e5a2217f3590b62ea31990672e31fbc5370b41", size = 247078 }, + { url = "https://files.pythonhosted.org/packages/97/7e/b790b4ab9605010816a91df26a715f163e228d60eb36c947c3118fb65190/zope.interface-6.1-cp310-cp310-win_amd64.whl", hash = "sha256:239a4a08525c080ff833560171d23b249f7f4d17fcbf9316ef4159f44997616f", size = 204155 }, + { url = "https://files.pythonhosted.org/packages/4a/0b/1d8817b8a3631384a26ff7faa4c1f3e6726f7e4950c3442721cfef2c95eb/zope.interface-6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9ffdaa5290422ac0f1688cb8adb1b94ca56cee3ad11f29f2ae301df8aecba7d1", size = 202441 }, + { url = "https://files.pythonhosted.org/packages/3e/1f/43557bb2b6e8537002a5a26af9b899171e26ddfcdf17a00ff729b00c036b/zope.interface-6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34c15ca9248f2e095ef2e93af2d633358c5f048c49fbfddf5fdfc47d5e263736", size = 202530 }, + { url = "https://files.pythonhosted.org/packages/37/a1/5d2b265f4b7371630cad5873d0873965e35ca3de993d11b9336c720f7259/zope.interface-6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b012d023b4fb59183909b45d7f97fb493ef7a46d2838a5e716e3155081894605", size = 249584 }, + { url = "https://files.pythonhosted.org/packages/8b/6d/547bfa7465e5b296adba0aff5c7ace1150f2a9e429fbf6c33d6618275162/zope.interface-6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:97806e9ca3651588c1baaebb8d0c5ee3db95430b612db354c199b57378312ee8", size = 243737 }, + { url = "https://files.pythonhosted.org/packages/db/5f/46946b588c43eb28efe0e46f4cf455b1ed8b2d1ea62a21b0001c6610662f/zope.interface-6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fddbab55a2473f1d3b8833ec6b7ac31e8211b0aa608df5ab09ce07f3727326de", size = 249104 }, + { url = "https://files.pythonhosted.org/packages/6c/9c/9d3c0e7e5362ea59da3c42b3b2b9fc073db433a0fe3bc6cae0809ccec395/zope.interface-6.1-cp311-cp311-win_amd64.whl", hash = "sha256:a0da79117952a9a41253696ed3e8b560a425197d4e41634a23b1507efe3273f1", size = 204155 }, + { url = "https://files.pythonhosted.org/packages/3c/91/68a0bbc97c2554f87d39572091954e94d043bcd83897cd6a779ca85cb5cc/zope.interface-6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8bb9c990ca9027b4214fa543fd4025818dc95f8b7abce79d61dc8a2112b561a", size = 202757 }, + { url = "https://files.pythonhosted.org/packages/e1/84/850092a8ab7e87a3ea615daf3f822f7196c52592e3e92f264621b4cfe5a2/zope.interface-6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b51b64432eed4c0744241e9ce5c70dcfecac866dff720e746d0a9c82f371dfa7", size = 202654 }, + { url = "https://files.pythonhosted.org/packages/57/23/508f7f79619ae4e025f5b264a9283efc3c805ed4c0ad75cb28c091179ced/zope.interface-6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa6fd016e9644406d0a61313e50348c706e911dca29736a3266fc9e28ec4ca6d", size = 254400 }, + { url = "https://files.pythonhosted.org/packages/7c/0d/db0ccf0d12767015f23b302aebe98d5eca218aaadc70c2e3908b85fecd2a/zope.interface-6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c8cf55261e15590065039696607f6c9c1aeda700ceee40c70478552d323b3ff", size = 248853 }, + { url = "https://files.pythonhosted.org/packages/fd/4f/8e80173ebcdefe0ff4164444c22b171cf8bd72533026befc2adf079f3ac8/zope.interface-6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e30506bcb03de8983f78884807e4fd95d8db6e65b69257eea05d13d519b83ac0", size = 255127 }, + { url = "https://files.pythonhosted.org/packages/0f/d5/81f9789311d9773a02ed048af7452fc6cedce059748dba956c1dc040340a/zope.interface-6.1-cp312-cp312-win_amd64.whl", hash = "sha256:e33e86fd65f369f10608b08729c8f1c92ec7e0e485964670b4d2633a4812d36b", size = 204268 }, +] diff --git a/misc/release/pump-version.sh b/misc/release/pump-version.sh index b90d265bf9..e7fbae34dc 100755 --- a/misc/release/pump-version.sh +++ b/misc/release/pump-version.sh @@ -73,7 +73,7 @@ if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then npm --prefix web i --package-lock-only npm --prefix e2e version "$SERVER_PUMP" npm --prefix e2e i --package-lock-only - poetry --directory machine-learning version "$SERVER_PUMP" + uvx --from=toml-cli toml set --toml-path=pyproject.toml project.version "$SERVER_PUMP" fi if [ "$CURRENT_MOBILE" != "$NEXT_MOBILE" ]; then diff --git a/mobile/.fvmrc b/mobile/.fvmrc index 691c22dd17..b94131913e 100644 --- a/mobile/.fvmrc +++ b/mobile/.fvmrc @@ -1,3 +1,3 @@ { - "flutter": "3.24.5" + "flutter": "3.29.1" } diff --git a/mobile/.gitignore b/mobile/.gitignore index 0db731f953..5f6e15354f 100644 --- a/mobile/.gitignore +++ b/mobile/.gitignore @@ -45,6 +45,7 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release +android/app/.cxx # Fastlane ios/fastlane/report.xml @@ -55,4 +56,4 @@ default.isar.lock libisar.so # FVM Version -.fvm/ +.fvm/ \ No newline at end of file diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 629c71a92d..4e20acb72a 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -65,30 +65,38 @@ custom_lint: allowed: # required / wanted - lib/entities/*.entity.dart - - lib/repositories/{album,asset,backup,database,etag,exif_info,user}.repository.dart + - lib/repositories/{album,asset,backup,database,etag,exif_info,user,timeline,partner}.repository.dart + - lib/infrastructure/entities/*.entity.dart + - lib/infrastructure/repositories/{store,db,log,exif}.repository.dart + - lib/providers/infrastructure/db.provider.dart # acceptable exceptions for the time being (until Isar is fully replaced) + - lib/providers/app_life_cycle.provider.dart - integration_test/test_utils/general_helper.dart - lib/main.dart - lib/pages/album/album_asset_selection.page.dart - lib/routing/router.dart - lib/services/immich_logger.service.dart # not really a service... more a util - - lib/utils/{db,migration,renderlist_generator}.dart + - lib/utils/{db,migration}.dart + - lib/utils/bootstrap.dart - lib/widgets/asset_grid/asset_grid_data_structure.dart - test/**.dart # refactor the remaining providers - - lib/providers/{archive,asset,authentication,db,favorite,partner,trash,user}.provider.dart - - lib/providers/{album/album,album/shared_album,asset_viewer/asset_stack,asset_viewer/render_list,backup/backup,search/all_motion_photos,search/recently_added_asset}.provider.dart + - lib/providers/db.provider.dart - import_rule_openapi: message: openapi must only be used through ApiRepositories restrict: package:openapi allowed: - # requried / wanted + # required / wanted - lib/repositories/*_api.repository.dart + - lib/infrastructure/repositories/*_api.repository.dart + - lib/infrastructure/utils/*.converter.dart # acceptable exceptions for the time being - lib/entities/{album,asset,exif_info,user}.entity.dart # to convert DTOs to entities - lib/utils/{image_url_builder,openapi_patching}.dart # utils are fine - test/modules/utils/openapi_patching_test.dart # filename is self-explanatory... + - lib/domain/services/sync_stream.service.dart # Making sure to comply with the type from database + # refactor - lib/models/map/map_marker.model.dart - lib/models/server_info/server_{config,disk_info,features,version}.model.dart @@ -110,51 +118,81 @@ custom_lint: - test/**.dart dart_code_metrics: - metrics: - cyclomatic-complexity: 20 - number-of-parameters: 4 - maximum-nesting-level: 5 + extends: + - recommended rules: # Common - - avoid-accessing-collections-by-constant-index + - arguments-ordering: + last: + - child + - children - avoid-accessing-other-classes-private-members - - avoid-cascade-after-if-null + - avoid-assigning-to-static-field + - avoid-assignments-as-conditions + - avoid-async-call-in-sync-function - avoid-collapsible-if - - avoid-collection-methods-with-unrelated-types - - avoid-double-slash-imports - - avoid-duplicate-cascades - - avoid-duplicate-patterns - - avoid-generics-shadowing + - avoid-collection-equality-checks + - avoid-complex-loop-conditions + - avoid-declaring-call-method + - avoid-extensions-on-records + - avoid-function-type-in-records + - avoid-future-ignore - avoid-global-state + - avoid-inverted-boolean-checks + - avoid-late-final-reassignment + - avoid-local-functions + - avoid-negated-conditions + - avoid-nested-streams-and-futures + - avoid-referencing-subclasses + - avoid-unnecessary-continue + - avoid-unnecessary-nullable-return-type: false + - binary-expression-operand-order + - move-variable-outside-iteration + - pattern-fields-ordering + - prefer-abstract-final-static-class + - prefer-commenting-future-delayed + - prefer-early-return + - prefer-first + - prefer-immediate-return + - prefer-last + - prefer-simpler-boolean-expressions + - prefer-switch-expression + - prefer-type-over-var + - use-existing-destructuring + - use-existing-variable # Flutter - - add-copy-with: - file-name-pattern: '.model.dart' - - always-remove-listener - avoid-border-all - - avoid-empty-setstate + - avoid-complex-arithmetic-expressions - avoid-expanded-as-spacer - - avoid-incomplete-copy-with + - avoid-if-with-many-branches - avoid-inherited-widget-in-initstate - avoid-late-context - - avoid-recursive-widget-calls - avoid-returning-widgets - avoid-shrink-wrap-in-lists - avoid-single-child-column-or-row - - avoid-state-constructors - avoid-stateless-widget-initialized-fields - - avoid-unnecessary-overrides-in-state - - avoid-unnecessary-stateful-widgets - avoid-wrapping-in-padding - - dispose-fields + - prefer-align-over-container - prefer-const-border-radius + - prefer-correct-callback-field-name: false - prefer-correct-edge-insets-constructor - - prefer-dedicated-media-query-methods - prefer-define-hero-tag - prefer-extracting-callbacks - - prefer-single-widget-per-file: - ignore-private-widgets: true + - prefer-for-loop-in-children + - prefer-match-file-name: false - prefer-sliver-prefix + - prefer-spacing - prefer-text-rich + - prefer-transform-over-container - prefer-using-list-view - - proper-super-calls - - use-setstate-synchronously + - prefer-widget-private-members: + ignore-static: true + - use-closest-build-context + # riverpod + - avoid-calling-notifier-members-inside-build + - avoid-notifier-constructors + - avoid-ref-read-inside-build + - avoid-ref-watch-outside-build + - avoid-unnecessary-consumer-widgets + - dispose-provided-instances + - use-ref-read-synchronously diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index ac57884eef..eb81dc267b 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -93,9 +93,9 @@ - + diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackupWorker.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackupWorker.kt index c4c87ff519..0fb75b002c 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackupWorker.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackupWorker.kt @@ -221,7 +221,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct .setContentTitle(title) .setTicker(title) .setContentText(content) - .setSmallIcon(R.mipmap.ic_launcher) + .setSmallIcon(R.drawable.notification_icon) .build() notificationManager.notify(individualTag, NOTIFICATION_ERROR_ID, notification) } @@ -260,7 +260,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct var builder = if (isDetail) notificationDetailBuilder else notificationBuilder if (builder == null) { builder = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID) - .setSmallIcon(R.mipmap.ic_launcher) + .setSmallIcon(R.drawable.notification_icon) .setOnlyAlertOnce(true) .setOngoing(true) if (isDetail) { diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index eec5f8bc88..1356c468ab 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 177, - "android.injected.version.name" => "1.125.1", + "android.injected.version.code" => 187, + "android.injected.version.name" => "1.129.0", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/assets/i18n/ar-JO.json b/mobile/assets/i18n/ar-JO.json index b5e85e1075..1bf6f2e6ad 100644 --- a/mobile/assets/i18n/ar-JO.json +++ b/mobile/assets/i18n/ar-JO.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "حذف الرابط المشترك", "description_input_hint_text": "اضف وصفا...", "description_input_submit_error": "خطأ تحديث الوصف ، تحقق من السجل لمزيد من التفاصيل", + "description_search": "Hiking day in Sapa", "download_canceled": "Download canceled", "download_complete": "Download complete", "download_enqueue": "Download enqueued", @@ -247,6 +248,7 @@ "download_sucess_android": "The media has been downloaded to DCIM/Immich", "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "التاريخ و الوقت", + "edit_date_time_dialog_search_timezone": "Search timezone...", "edit_date_time_dialog_timezone": "وحدة زمنية", "edit_image_title": "Edit", "edit_location_dialog_title": "موقع", @@ -336,9 +338,9 @@ "login_form_back_button_text": "الرجوع للخلف", "login_form_button_text": "تسجيل الدخول", "login_form_email_hint": "yoursemail@email.com", - "login_form_endpoint_hint": "http: // your-server-ip: port/api", + "login_form_endpoint_hint": "http://your-server-ip:port", "login_form_endpoint_url": "url نقطة نهاية الخادم", - "login_form_err_http": "يرجى تحديد http: // أو https: //", + "login_form_err_http": "يرجى تحديد http:// أو https://", "login_form_err_invalid_email": "بريد إلكتروني خاطئ", "login_form_err_invalid_url": "URL غير صالح", "login_form_err_leading_whitespace": "قيادة المساحة البيضاء", @@ -455,14 +457,17 @@ "search_filter_camera_make": "صنع", "search_filter_camera_model": "نموذج", "search_filter_camera_title": "Select camera type", + "search_filter_contextual": "Search by context", "search_filter_date": "Date", "search_filter_date_interval": "{start} to {end}", "search_filter_date_title": "Select a date range", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "أرشيف", "search_filter_display_option_favorite": "مفضل", "search_filter_display_option_not_in_album": "ليس في الألبوم", "search_filter_display_options": "Display Options", "search_filter_display_options_title": "Display options", + "search_filter_filename": "Search by file name", "search_filter_location": "Location", "search_filter_location_city": "مدينة", "search_filter_location_country": "دولة", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Select media type", "search_filter_media_type_video": "شريط فيديو", "search_filter_people": "People", + "search_filter_people_hint": "Filter people", "search_filter_people_title": "Select people", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "فئات", "search_page_favorites": "المفضلة", "search_page_motion_photos": "الصور المتحركه", diff --git a/mobile/assets/i18n/ca-CA.json b/mobile/assets/i18n/ca.json similarity index 63% rename from mobile/assets/i18n/ca-CA.json rename to mobile/assets/i18n/ca.json index e34b46be40..e41ab4b183 100644 --- a/mobile/assets/i18n/ca-CA.json +++ b/mobile/assets/i18n/ca.json @@ -1,24 +1,35 @@ { - "action_common_cancel": "Cancel·la", - "action_common_update": "Actualitza", - "add_to_album_bottom_sheet_added": "S'ha afegit a {album}", - "add_to_album_bottom_sheet_already_exists": "Ja es troba en {album}", + "action_common_back": "Enrere", + "action_common_cancel": "Cancel·lar", + "action_common_clear": "Buida", + "action_common_confirm": "Confirmar", + "action_common_save": "Desa", + "action_common_select": "Selecciona", + "action_common_update": "Actualitzar", + "add_a_name": "Afegeix un nom", + "add_endpoint": "afegir endpoint", + "add_to_album_bottom_sheet_added": "Added to {album}", + "add_to_album_bottom_sheet_already_exists": "Already in {album}", "advanced_settings_log_level_title": "Log level: {}", "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", "advanced_settings_prefer_remote_title": "Prefereix imatges remotes", + "advanced_settings_proxy_headers_subtitle": "Definiu les capçaleres de proxy que Immich per enviar amb cada sol·licitud de xarxa", + "advanced_settings_proxy_headers_title": "Capçaleres de proxy", "advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.", "advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates", - "advanced_settings_tile_subtitle": "Configuració avançada", + "advanced_settings_tile_subtitle": "Advanced user's settings", "advanced_settings_tile_title": "Avançat", "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", "advanced_settings_troubleshooting_title": "Resolució de problemes", "album_info_card_backup_album_excluded": "Exclosos", "album_info_card_backup_album_included": "Inclosos", - "album_thumbnail_card_item": "1 element", - "album_thumbnail_card_items": "{} elements", - "album_thumbnail_card_shared": " · Compartit", + "albums": "Àlbums", + "album_thumbnail_card_item": "1 item", + "album_thumbnail_card_items": "{} items", + "album_thumbnail_card_shared": " · Shared", "album_thumbnail_owned": "Owned", "album_thumbnail_shared_by": "Compartit per {}", + "album_viewer_appbar_delete_confirm": "Confirmes que vols suprimir aquest àlbum del teu compte?", "album_viewer_appbar_share_delete": "Esborra l'àlbum", "album_viewer_appbar_share_err_delete": "Error al esborrar l'àlbum", "album_viewer_appbar_share_err_leave": "Error al sortir de l'àlbum", @@ -28,25 +39,39 @@ "album_viewer_appbar_share_remove": "Treu de l'àlbum", "album_viewer_appbar_share_to": "Share To", "album_viewer_page_share_add_users": "Afegeix usuaris", + "all": "Tot", "all_people_page_title": "Persones", "all_videos_page_title": "Vídeos", "app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_title": "Sign out", + "archived": "Arxivat", "archive_page_no_archived_assets": "No s'ha trobat res arxivat", "archive_page_title": "Arxiu({})", - "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", - "asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping", - "asset_list_group_by_sub_title": "Group by", + "asset_action_delete_err_read_only": "No es poden esborrar el fitxer(s) de només lectura, ometent", + "asset_action_share_err_offline": "No s'ha pogut obtenir el fitxer(s) sense connexió, ometent", + "asset_list_group_by_sub_title": "Agrupar per", "asset_list_layout_settings_dynamic_layout_title": "Dynamic layout", "asset_list_layout_settings_group_automatically": "Automàtic", "asset_list_layout_settings_group_by": "Group assets by", "asset_list_layout_settings_group_by_month": "Month", "asset_list_layout_settings_group_by_month_day": "Month + day", - "asset_list_layout_sub_title": "Layout", + "asset_list_layout_sub_title": "Disseny", "asset_list_settings_subtitle": "Photo grid layout settings", "asset_list_settings_title": "Photo Grid", - "asset_viewer_settings_title": "Asset Viewer", + "asset_restored_successfully": "Element recuperat correctament", + "assets_deleted_permanently": "{} element(s) esborrats permanentment", + "assets_deleted_permanently_from_server": "{} element(s) esborrats permanentment del servidor d'Immich", + "assets_removed_permanently_from_device": "{} element(s) esborrat permanentment del dispositiu", + "assets_restored_successfully": "{} element(s) recuperats correctament", + "assets_trashed": "{} element(s) enviat a la paperera", + "assets_trashed_from_server": "{} element(s) enviat a la paperera del servidor d'Immich", + "asset_viewer_settings_subtitle": "Gestiona la configuració del visualitzador de la galeria", + "asset_viewer_settings_title": "Visor d'arxius", + "automatic_endpoint_switching_subtitle": "Connecteu-vos localment a través de la Wi-Fi designada quan estigui disponible i utilitzeu connexions alternatives en altres llocs", + "automatic_endpoint_switching_title": "Canvi automàtic d'URL", + "background_location_permission": "Permís d'ubicació en segon pla", + "background_location_permission_content": "Per canviar de xarxa quan s'executa en segon pla, Immich ha de *sempre* tenir accés a la ubicació precisa perquè l'aplicació pugui llegir el nom de la xarxa Wi-Fi", "backup_album_selection_page_albums_device": "Àlbums al dispositiu ({})", "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", @@ -111,6 +136,8 @@ "backup_manual_in_progress": "Upload already in progress. Try after sometime", "backup_manual_success": "Success", "backup_manual_title": "Upload status", + "backup_options_page_title": "Opcions de còpia de seguretat", + "backup_setting_subtitle": "Gestiona la configuració de càrrega en segon pla i en primer pla", "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", "cache_settings_clear_cache_button": "Clear cache", "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.", @@ -129,73 +156,136 @@ "cache_settings_tile_subtitle": "Control the local storage behaviour", "cache_settings_tile_title": "Local Storage", "cache_settings_title": "Configuració de la memòria cau", + "cancel": "Cancel·la", + "canceled": "Cancel·lat", + "change_display_order": "Canvia l'ordre de visualització", "change_password_form_confirm_password": "Confirma la contrasenya", "change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", "change_password_form_new_password": "New Password", "change_password_form_password_mismatch": "Passwords do not match", "change_password_form_reenter_new_password": "Re-enter New Password", + "check_corrupt_asset_backup": "Comprovar les còpies de seguretat corruptes", + "check_corrupt_asset_backup_button": "Realitzar comprovació", + "check_corrupt_asset_backup_description": "Executeu aquesta comprovació només mitjançant Wi-Fi i un cop s'hagi fet una còpia de seguretat de tots els actius. El procediment pot trigar uns minuts.", + "client_cert_dialog_msg_confirm": "OK", + "client_cert_enter_password": "Introdueix la contrasenya", + "client_cert_import": "Importar", + "client_cert_import_success_msg": "S'ha importat el certificat del client", + "client_cert_invalid_msg": "Fitxer de certificat no vàlid o contrasenya incorrecta", + "client_cert_remove": "Eliminar", + "client_cert_remove_msg": "S'ha eliminat el certificat del client", + "client_cert_subtitle": "Només admet el format PKCS12 (.p12, .pfx). La importació/eliminació de certificats només està disponible abans d'iniciar sessió", + "client_cert_title": "Certificat de client SSL", "common_add_to_album": "Add to album", "common_change_password": "Change Password", "common_create_new_album": "Crea un àlbum nou", "common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.", "common_shared": "Compartit", + "completed": "Completat", + "contextual_search": "Sortida del sol a la platja", "control_bottom_app_bar_add_to_album": "Add to album", "control_bottom_app_bar_album_info": "{} elements", "control_bottom_app_bar_album_info_shared": "{} elements - Compartits", "control_bottom_app_bar_archive": "Arxiu", "control_bottom_app_bar_create_new_album": "Crea un àlbum nou", "control_bottom_app_bar_delete": "Esborra", - "control_bottom_app_bar_delete_from_immich": "Delete from Immich", - "control_bottom_app_bar_delete_from_local": "Delete from device", + "control_bottom_app_bar_delete_from_immich": "Suprimeix del Immich", + "control_bottom_app_bar_delete_from_local": "Suprimeix del dispositiu", + "control_bottom_app_bar_download": "Descarrega", + "control_bottom_app_bar_edit": "Edita", "control_bottom_app_bar_edit_location": "Edit Location", "control_bottom_app_bar_edit_time": "Edit Date & Time", "control_bottom_app_bar_favorite": "Preferit", "control_bottom_app_bar_share": "Share", "control_bottom_app_bar_share_to": "Share To", "control_bottom_app_bar_stack": "Stack", - "control_bottom_app_bar_trash_from_immich": "Move to Trash", + "control_bottom_app_bar_trash_from_immich": "Mou a paperera", "control_bottom_app_bar_unarchive": "Desarxiva", "control_bottom_app_bar_unfavorite": "Unfavorite", "control_bottom_app_bar_upload": "Upload", + "create_album": "Crear àlbum", "create_album_page_untitled": "Untitled", + "create_new": "CREAR NOU", "create_shared_album_page_create": "Create", "create_shared_album_page_share": "Comparteix", "create_shared_album_page_share_add_assets": "AFEGEIX ELEMENTS", "create_shared_album_page_share_select_photos": "Escull fotografies", + "crop": "Retalla", "curated_location_page_title": "Localitzacions", "curated_object_page_title": "Coses", + "current_server_address": "Current server address", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", "delete_dialog_alert": "These items will be permanently deleted from Immich and from your device", - "delete_dialog_alert_local": "These items will be permanently removed from your device but still be available on the Immich server", - "delete_dialog_alert_local_non_backed_up": "Some of the items aren't backed up to Immich and will be permanently removed from your device", - "delete_dialog_alert_remote": "These items will be permanently deleted from the Immich server", + "delete_dialog_alert_local": "Aquests elements s'eliminaran permanentment del vostre dispositiu, però encara estaran disponibles al servidor Immich", + "delete_dialog_alert_local_non_backed_up": "Alguns dels elements no tenen còpia de seguretat a Immich i s'eliminaran permanentment del dispositiu", + "delete_dialog_alert_remote": "Aquests elements s'eliminaran permanentment del servidor Immich", "delete_dialog_cancel": "Cancel·la", "delete_dialog_ok": "Esborra", - "delete_dialog_ok_force": "Delete Anyway", + "delete_dialog_ok_force": "Suprimeix de totes maneres", "delete_dialog_title": "Esborra permanentment", - "delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only", - "delete_local_dialog_ok_force": "Delete Anyway", + "delete_local_dialog_ok_backed_up_only": "Esborrar només les que tinguin còpia de seguretat", + "delete_local_dialog_ok_force": "Suprimeix de totes maneres", "delete_shared_link_dialog_content": "Are you sure you want to delete this shared link?", "delete_shared_link_dialog_title": "Delete Shared Link", "description_input_hint_text": "Afegeix descripció...", "description_input_submit_error": "Error updating description, check the log for more details", - "edit_date_time_dialog_date_time": "Date and Time", - "edit_date_time_dialog_timezone": "Timezone", - "edit_location_dialog_title": "Location", + "description_search": "Jornada de senderisme a Sapa", + "download_canceled": "Descàrrega cancel·lada", + "download_complete": "Descàrrega completada", + "download_enqueue": "Descàrrega en cua", + "download_error": "Error de descàrrega", + "download_failed": "Descàrrega ha fallat", + "download_filename": "arxiu: {}", + "download_finished": "Descàrrega acabada", + "downloading": "Descarregant...", + "downloading_media": "Descàrrega multimèdia", + "download_notfound": "No s'ha trobat la descàrrega", + "download_paused": "Descàrrega pausada", + "download_started": "Descàrrega ha començat", + "download_sucess": "Descarregat amb èxit", + "download_sucess_android": "El multimedia s'ha descarregat a DCIM/Immich", + "download_waiting_to_retry": "Esperant per tornar-ho a intentar", + "edit_date_time_dialog_date_time": "Data i Hora", + "edit_date_time_dialog_search_timezone": "Cerca zona horària...", + "edit_date_time_dialog_timezone": "Zona horària", + "edit_image_title": "Editar", + "edit_location_dialog_title": "Ubicació", + "end_date": "Data final", + "enqueued": "En cua", + "enter_wifi_name": "Introdueix el nom de WiFi", + "error_change_sort_album": "No s'ha pogut canviar l'ordre d'ordenació dels àlbums", + "error_saving_image": "Error: {}", "exif_bottom_sheet_description": "Afegeix descripció", "exif_bottom_sheet_details": "DETALLS", "exif_bottom_sheet_location": "UBICACIÓ", "exif_bottom_sheet_location_add": "Add a location", - "exif_bottom_sheet_people": "PEOPLE", - "exif_bottom_sheet_person_add_person": "Add name", + "exif_bottom_sheet_people": "PERSONES", + "exif_bottom_sheet_person_add_person": "Afegir nom", "experimental_settings_new_asset_list_subtitle": "Work in progress", "experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_subtitle": "Use at your own risk!", "experimental_settings_title": "Experimental", + "external_network": "Xarxa externa", + "external_network_sheet_info": "Quan no estigui a la xarxa WiFi preferida, l'aplicació es connectarà al servidor mitjançant el primer dels URL següents a què pot arribar, començant de dalt a baix.", + "failed": "Fallat", + "favorites": "Favorits", "favorites_page_no_favorites": "No s'han trobat preferits", "favorites_page_title": "Favorites", + "filename_search": "Nom o extensió del fitxer", + "filter": "Filtrar", + "get_wifiname_error": "No s'ha pogut obtenir el nom de la Wi-Fi. Assegureu-vos que heu concedit els permisos necessaris i que esteu connectat a una xarxa Wi-Fi", + "grant_permission": "Grant permission", + "haptic_feedback_switch": "Activa la resposta hàptica", + "haptic_feedback_title": "Resposta Hàptica", + "header_settings_add_header_tip": "Afegeix Capçalera", + "header_settings_field_validator_msg": "El valor no pot estar buit", + "header_settings_header_name_input": "Nom de la capçalera", + "header_settings_header_value_input": "Valor de la capçalera", + "header_settings_page_title": "Capçaleres de proxy", + "headers_settings_tile_subtitle": "Definiu les capçaleres de proxy que l'aplicació hauria d'enviar amb cada sol·licitud de xarxa", + "headers_settings_tile_title": "Custom proxy headers", "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", "home_page_add_to_album_success": "Added {added} assets to album {album}.", @@ -204,16 +294,22 @@ "home_page_archive_err_partner": "Can not archive partner assets, skipping", "home_page_building_timeline": "Building the timeline", "home_page_delete_err_partner": "Can not delete partner assets, skipping", - "home_page_delete_remote_err_local": "Local assets in delete remote selection, skipping", + "home_page_delete_remote_err_local": "Elements locals a la selecció d'eliminació remota, ometent", "home_page_favorite_err_local": "Can not favorite local assets yet, skipping", "home_page_favorite_err_partner": "Can not favorite partner assets yet, skipping", "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", "home_page_share_err_local": "Can not share local assets via link, skipping", "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", + "ignore_icloud_photos": "Ignora fotos d'iCloud", + "ignore_icloud_photos_description": "Les fotos emmagatzemades a iCloud no es penjaran al servidor Immich", + "image_saved_successfully": "Imatge desada", "image_viewer_page_state_provider_download_error": "Download Error", - "image_viewer_page_state_provider_download_started": "Download Started", + "image_viewer_page_state_provider_download_started": "Descàrrega començada", "image_viewer_page_state_provider_download_success": "Download Success", "image_viewer_page_state_provider_share_error": "Share Error", + "invalid_date": "Data invàlida", + "invalid_date_format": "Format de data invàlid", + "library": "Llibreria", "library_page_albums": "Àlbums", "library_page_archive": "Arxiu", "library_page_device_albums": "Àlbums al Dispositiu", @@ -226,19 +322,23 @@ "library_page_sort_most_oldest_photo": "Oldest photo", "library_page_sort_most_recent_photo": "Most recent photo", "library_page_sort_title": "Album title", - "location_picker_choose_on_map": "Choose on map", - "location_picker_latitude": "Latitude", - "location_picker_latitude_error": "Enter a valid latitude", + "local_network": "Xarxa local", + "local_network_sheet_info": "L'aplicació es connectarà al servidor mitjançant aquest URL quan utilitzeu la xarxa Wi-Fi especificada", + "location_permission": "Permís d'ubicació", + "location_permission_content": "Per utilitzar la funció de canvi automàtic, Immich necessita un permís de ubicació precisa perquè pugui llegir el nom de la xarxa WiFi actual", + "location_picker_choose_on_map": "Escollir en el mapa", + "location_picker_latitude": "Latitud", + "location_picker_latitude_error": "Introdueix una latitud vàlida", "location_picker_latitude_hint": "Enter your latitude here", - "location_picker_longitude": "Longitude", - "location_picker_longitude_error": "Enter a valid longitude", - "location_picker_longitude_hint": "Enter your longitude here", + "location_picker_longitude": "Longitud", + "location_picker_longitude_error": "Introdueix una longitud vàlida", + "location_picker_longitude_hint": "Introdueix aquí la longitud", "login_disabled": "Login has been disabled", "login_form_api_exception": "API exception. Please check the server URL and try again.", "login_form_back_button_text": "Back", "login_form_button_text": "Entra", "login_form_email_hint": "elteu@correu.cat", - "login_form_endpoint_hint": "http://ip-del-servidor:port/api", + "login_form_endpoint_hint": "http://ip-del-servidor:port", "login_form_endpoint_url": "URL del servidor", "login_form_err_http": "Especifica http:// o https://", "login_form_err_invalid_email": "Adreça de correu electrònic no vàlida", @@ -258,12 +358,12 @@ "login_form_server_error": "Could not connect to server.", "login_password_changed_error": "There was an error updating your password", "login_password_changed_success": "Password updated successfully", - "map_assets_in_bound": "{} photo", - "map_assets_in_bounds": "{} photos", + "map_assets_in_bound": "{} foto", + "map_assets_in_bounds": "{} fotos", "map_cannot_get_user_location": "Cannot get user's location", "map_location_dialog_cancel": "Cancel", "map_location_dialog_yes": "Yes", - "map_location_picker_page_use_location": "Use this location", + "map_location_picker_page_use_location": "Utilitzar aquesta ubicació", "map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?", "map_location_service_disabled_title": "Location Service disabled", "map_no_assets_in_bounds": "No photos in this area", @@ -271,32 +371,44 @@ "map_no_location_permission_title": "Location Permission denied", "map_settings_dark_mode": "Dark mode", "map_settings_date_range_option_all": "All", - "map_settings_date_range_option_day": "Past 24 hours", - "map_settings_date_range_option_days": "Past {} days", - "map_settings_date_range_option_year": "Past year", - "map_settings_date_range_option_years": "Past {} years", + "map_settings_date_range_option_day": "Últimes 24 hores", + "map_settings_date_range_option_days": "Darrers {} dies", + "map_settings_date_range_option_year": "Any passat", + "map_settings_date_range_option_years": "Darrers {} anys", "map_settings_dialog_cancel": "Cancel", "map_settings_dialog_save": "Save", "map_settings_dialog_title": "Map Settings", "map_settings_include_show_archived": "Include Archived", + "map_settings_include_show_partners": "Incloure companys", "map_settings_only_relative_range": "Date range", "map_settings_only_show_favorites": "Show Favorite Only", - "map_settings_theme_settings": "Map Theme", + "map_settings_theme_settings": "Tema del Mapa", "map_zoom_to_see_photos": "Zoom out to see photos", - "memories_all_caught_up": "All caught up", - "memories_check_back_tomorrow": "Check back tomorrow for more memories", - "memories_start_over": "Start Over", - "memories_swipe_to_close": "Swipe up to close", + "memories_all_caught_up": "Posat al dia", + "memories_check_back_tomorrow": "Torna demà per veure més records", + "memories_start_over": "Torna a començar", + "memories_swipe_to_close": "Llisca per tancar", + "memories_year_ago": "Fa un any", + "memories_years_ago": "Fa {} anys", "monthly_title_text_date_format": "MMMM y", "motion_photos_page_title": "Motion Photos", - "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", - "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "multiselect_grid_edit_date_time_err_read_only": "No es pot canviar la data del fitxer(s) de només lectura, ometent", + "multiselect_grid_edit_gps_err_read_only": "No es pot canviar la localització de fitxers de només lectura. Saltant.", + "my_albums": "Els meus àlbums", + "networking_settings": "Xarxes", + "networking_subtitle": "Gestiona la configuració del endpoint del servidor", + "no_assets_to_show": "No hi ha elements per mostrar", + "no_name": "Sense nom", "notification_permission_dialog_cancel": "Cancel·la", "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", "notification_permission_dialog_settings": "Configuració", "notification_permission_list_tile_content": "Grant permission to enable notifications.", "notification_permission_list_tile_enable_button": "Activa les notificacions", "notification_permission_list_tile_title": "Notification Permission", + "not_selected": "No seleccionat", + "on_this_device": "En aquest dispositiu", + "partner_list_user_photos": "fotos de {user}", + "partner_list_view_all": "Veure tot", "partner_page_add_partner": "Afegeix company", "partner_page_empty_message": "Your photos are not yet shared with any partner.", "partner_page_no_more_users": "No more users to add", @@ -306,6 +418,9 @@ "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", "partner_page_stop_sharing_title": "Stop sharing your photos?", "partner_page_title": "Company", + "partners": "Companys", + "paused": "Pausat", + "people": "Persones", "permission_onboarding_back": "Back", "permission_onboarding_continue_anyway": "Continue anyway", "permission_onboarding_get_started": "Get started", @@ -316,7 +431,9 @@ "permission_onboarding_permission_granted": "Permission granted! You are all set.", "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", "permission_onboarding_request": "Immich requires permission to view your photos and videos.", - "preferences_settings_title": "Preferences", + "places": "Llocs", + "preferences_settings_subtitle": "Gestiona les preferències de l'aplicació", + "preferences_settings_title": "Preferències", "profile_drawer_app_logs": "Logs", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", "profile_drawer_client_out_of_date_minor": "Mobile App is out of date. Please update to the latest minor version.", @@ -328,9 +445,44 @@ "profile_drawer_settings": "Settings", "profile_drawer_sign_out": "Tanca la sessió", "profile_drawer_trash": "Trash", + "recently_added": "Afegit recentment", "recently_added_page_title": "Recently Added", - "scaffold_body_error_occurred": "Error occurred", + "save": "Desa", + "save_to_gallery": "Desa a galeria", + "scaffold_body_error_occurred": "S'ha produït un error", + "search_albums": "Cerca àlbums", "search_bar_hint": "Search your photos", + "search_filter_apply": "Aplicar filtre", + "search_filter_camera": "Càmera", + "search_filter_camera_make": "Marca", + "search_filter_camera_model": "Model", + "search_filter_camera_title": "Selecciona el tipus de càmera", + "search_filter_contextual": "Cerca per contexte", + "search_filter_date": "Data", + "search_filter_date_interval": "{start} a {end}", + "search_filter_date_title": "Selecciona un rang de dates", + "search_filter_description": "Cerca per descripció", + "search_filter_display_option_archive": "Arxivat", + "search_filter_display_option_favorite": "Favorit", + "search_filter_display_option_not_in_album": "No en àlbum", + "search_filter_display_options": "Opcions de Visualització", + "search_filter_display_options_title": "Opcions de visualització", + "search_filter_filename": "Cerca pel nom del fitxer", + "search_filter_location": "Ubicació", + "search_filter_location_city": "Ciutat", + "search_filter_location_country": "País", + "search_filter_location_state": "Estat", + "search_filter_location_title": "Selecciona l'ubicació", + "search_filter_media_type": "Tipus de multimèdia", + "search_filter_media_type_all": "Tot", + "search_filter_media_type_image": "Imatge", + "search_filter_media_type_title": "Selecciona tipus de multimèdia", + "search_filter_media_type_video": "Vídeo", + "search_filter_people": "Persones", + "search_filter_people_hint": "Filtra persones", + "search_filter_people_title": "Selecciona persones", + "search_no_more_result": "No més resultats", + "search_no_result": "No s'han trobat resultats, proveu un terme de cerca o una combinació diferents", "search_page_categories": "Categories", "search_page_favorites": "Preferides", "search_page_motion_photos": "Fotografies animades", @@ -347,6 +499,7 @@ "search_page_places": "Llocs", "search_page_recently_added": "Afegit recentment", "search_page_screenshots": "Captures de pantalla", + "search_page_search_photos_videos": "Cerca les teves fotos i vídeos", "search_page_selfies": "Autofotos", "search_page_things": "Coses", "search_page_videos": "Videos", @@ -359,6 +512,7 @@ "select_additional_user_for_sharing_page_suggestions": "Suggeriments", "select_user_for_sharing_page_err_album": "Error al crear l'àlbum", "select_user_for_sharing_page_share_suggestions": "Suggestions", + "server_endpoint": "Endpoint de Servidor", "server_info_box_app_version": "Versió de l'aplicació", "server_info_box_latest_release": "Latest Version", "server_info_box_server_url": "Server URL", @@ -368,6 +522,10 @@ "setting_image_viewer_original_title": "Load original image", "setting_image_viewer_preview_subtitle": "Enable to load a medium-resolution image. Disable to either directly load the original or only use the thumbnail.", "setting_image_viewer_preview_title": "Load preview image", + "setting_image_viewer_title": "Imatges", + "setting_languages_apply": "Aplicar", + "setting_languages_subtitle": "Canvia el llenguatge de l'aplicació", + "setting_languages_title": "Idiomes", "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}", "setting_notifications_notify_hours": "{} hours", "setting_notifications_notify_immediately": "immediately", @@ -382,9 +540,15 @@ "setting_notifications_total_progress_title": "Show background backup total progress", "setting_pages_app_bar_settings": "Settings", "settings_require_restart": "Please restart Immich to apply this setting", + "setting_video_viewer_looping_subtitle": "Habilita per reproduir automàticament un vídeo al visualitzador de detalls.", + "setting_video_viewer_looping_title": "Bucle", + "setting_video_viewer_original_video_subtitle": "Quan reproduïu un vídeo des del servidor, reproduïu l'original encara que hi hagi una transcodificació disponible. Pot conduir a l'amortització. Els vídeos disponibles localment es reprodueixen en qualitat original independentment d'aquesta configuració.", + "setting_video_viewer_original_video_title": "Força el vídeo original", + "setting_video_viewer_title": "Vídeos", "share_add": "Afegeix", "share_add_photos": "Afegeix fotografies", "share_add_title": "Afegeix un títol", + "share_assets_selected": "{} seleccionats", "share_create_album": "Crea un àlbum", "shared_album_activities_input_disable": "Comment is disabled", "shared_album_activities_input_hint": "Say something", @@ -398,6 +562,7 @@ "shared_album_section_people_owner_label": "Owner", "shared_album_section_people_title": "PEOPLE", "share_dialog_preparing": "Preparing...", + "shared_intent_upload_button_progress_text": "{} / {} Pujat", "shared_link_app_bar_title": "Shared Links", "shared_link_clipboard_copied_massage": "Copied to clipboard", "shared_link_clipboard_text": "Link: {}\nPassword: {}", @@ -412,13 +577,15 @@ "shared_link_edit_description": "Description", "shared_link_edit_description_hint": "Enter the share description", "shared_link_edit_expire_after": "Expire after", - "shared_link_edit_expire_after_option_day": "1 day", - "shared_link_edit_expire_after_option_days": "{} days", - "shared_link_edit_expire_after_option_hour": "1 hour", - "shared_link_edit_expire_after_option_hours": "{} hours", - "shared_link_edit_expire_after_option_minute": "1 minute", - "shared_link_edit_expire_after_option_minutes": "{} minutes", + "shared_link_edit_expire_after_option_day": "1 dia", + "shared_link_edit_expire_after_option_days": "{} dies", + "shared_link_edit_expire_after_option_hour": "1 hora", + "shared_link_edit_expire_after_option_hours": "{} hores", + "shared_link_edit_expire_after_option_minute": "1 minut", + "shared_link_edit_expire_after_option_minutes": "{} minuts", + "shared_link_edit_expire_after_option_months": "{} mesos", "shared_link_edit_expire_after_option_never": "Never", + "shared_link_edit_expire_after_option_year": "any {}", "shared_link_edit_password": "Password", "shared_link_edit_password_hint": "Enter the share password", "shared_link_edit_show_meta": "Show metadata", @@ -426,65 +593,89 @@ "shared_link_empty": "You don't have any shared links", "shared_link_error_server_url_fetch": "Cannot fetch the server url", "shared_link_expired": "Expired", - "shared_link_expires_day": "Expires in {} day", - "shared_link_expires_days": "Expires in {} days", - "shared_link_expires_hour": "Expires in {} hour", - "shared_link_expires_hours": "Expires in {} hours", - "shared_link_expires_minute": "Expires in {} minute", + "shared_link_expires_day": "Caduca d'aquí a {} dia", + "shared_link_expires_days": "Caduca d'aquí a {} dies", + "shared_link_expires_hour": "Caduca d'aquí a {} hora", + "shared_link_expires_hours": "Caduca d'aquí a {} hores", + "shared_link_expires_minute": "Caduca d'aquí a {} minut", "shared_link_expires_minutes": "Expires in {} minutes", "shared_link_expires_never": "Expires ∞", - "shared_link_expires_second": "Expires in {} second", + "shared_link_expires_second": "Caduca d'aquí a {} segon", "shared_link_expires_seconds": "Expires in {} seconds", - "shared_link_info_chip_download": "Baixa", + "shared_link_individual_shared": "Individual compartit", + "shared_link_info_chip_download": "Download", "shared_link_info_chip_metadata": "EXIF", "shared_link_info_chip_upload": "Puja", "shared_link_manage_links": "Manage Shared links", - "share_done": "Fet", + "shared_link_public_album": "Àlbum públic", + "shared_links": "Enllaços compartits", + "share_done": "Done", + "shared_with_me": "Compartit amb mi", "share_invite": "Convida a l'àlbum", - "sharing_page_album": "Àlbums compartits", + "sharing_page_album": "Shared albums", "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", "sharing_page_empty_list": "EMPTY LIST", "sharing_silver_appbar_create_shared_album": "Crea àlbum compartit", "sharing_silver_appbar_shared_links": "Shared links", "sharing_silver_appbar_share_partner": "Comparteix amb un company", - "tab_controller_nav_library": "Bibilioteca", - "tab_controller_nav_photos": "Fotos", + "start_date": "Data inicial", + "sync": "Sincronitzar", + "sync_albums": "Sincronitzar àlbums", + "sync_albums_manual_subtitle": "Sincronitza tots els vídeos i fotos penjats amb els àlbums de còpia de seguretat seleccionats", + "sync_upload_album_setting_subtitle": "Creeu i pugeu les seves fotos i vídeos als àlbums seleccionats a Immich", + "tab_controller_nav_library": "Library", + "tab_controller_nav_photos": "Fotografies", "tab_controller_nav_search": "Cerca", "tab_controller_nav_sharing": "Compartint", "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", - "theme_setting_dark_mode_switch": "Modes fosc", + "theme_setting_colorful_interface_subtitle": "Apliqueu color primari a les superfícies de fons.", + "theme_setting_colorful_interface_title": "Interfície colorida", + "theme_setting_dark_mode_switch": "Dark mode", "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", "theme_setting_image_viewer_quality_title": "Image viewer quality", + "theme_setting_primary_color_subtitle": "Trieu un color per a les accions i els accents principals.", + "theme_setting_primary_color_title": "Color primari", + "theme_setting_system_primary_color_title": "Utilitza color de sistema", "theme_setting_system_theme_switch": "Automatic (Follow system setting)", "theme_setting_theme_subtitle": "Choose the app's theme setting", - "theme_setting_theme_title": "Tema", + "theme_setting_theme_title": "Theme", "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", "theme_setting_three_stage_loading_title": "Enable three-stage loading", "translated_text_options": "Options", - "trash_page_delete": "Elimina", - "trash_page_delete_all": "Elimina-ho tot", - "trash_page_empty_trash_btn": "Buida la paperera", + "trash": "Paperera", + "trash_emptied": "Paperera buidada", + "trash_page_delete": "Delete", + "trash_page_delete_all": "Delete All", + "trash_page_empty_trash_btn": "Empty trash", "trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich", "trash_page_empty_trash_dialog_ok": "Ok", "trash_page_info": "Trashed items will be permanently deleted after {} days", "trash_page_no_assets": "No trashed assets", - "trash_page_restore": "Recupera", - "trash_page_restore_all": "Recupera-ho tot", + "trash_page_restore": "Restore", + "trash_page_restore_all": "Restore All", "trash_page_select_assets_btn": "Select assets", "trash_page_select_btn": "Select", "trash_page_title": "Trash ({})", - "upload_dialog_cancel": "Cancel·la", + "upload": "Puja", + "upload_dialog_cancel": "Cancel", "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", "upload_dialog_ok": "Upload", "upload_dialog_title": "Upload Asset", + "uploading": "Pujant", + "upload_to_immich": "Puja a Immich ({})", + "use_current_connection": "utilitzar la connexió actual", + "validate_endpoint_error": "Per favor introdueix un URL vàlid", "version_announcement_overlay_ack": "Acknowledge", "version_announcement_overlay_release_notes": "release notes", "version_announcement_overlay_text_1": "Hi friend, there is a new release of", "version_announcement_overlay_text_2": "please take your time to visit the ", "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", + "videos": "Vídeos", "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", - "viewer_unstack": "Un-Stack" -} + "viewer_unstack": "Un-Stack", + "wifi_name": "Nom WiFi", + "your_wifi_name": "El teu nom WiFi" +} \ No newline at end of file diff --git a/mobile/assets/i18n/cs-CZ.json b/mobile/assets/i18n/cs-CZ.json index 0a1d84aff5..01d8661dc9 100644 --- a/mobile/assets/i18n/cs-CZ.json +++ b/mobile/assets/i18n/cs-CZ.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Odstranit sdílený odkaz", "description_input_hint_text": "Přidat popis...", "description_input_submit_error": "Chyba aktualizace popisu, další podrobnosti najdete v logu", + "description_search": "Pěší turistika v Sapě", "download_canceled": "Stahování zrušeno", "download_complete": "Stahování kompletní", "download_enqueue": "Stahování ve frontě", @@ -247,6 +248,7 @@ "download_sucess_android": "Média byla stažena do DCIM/Immich", "download_waiting_to_retry": "Čekání na opakovaný pokus", "edit_date_time_dialog_date_time": "Datum a čas", + "edit_date_time_dialog_search_timezone": "Hledat časové pásmo...", "edit_date_time_dialog_timezone": "Časové pásmo", "edit_image_title": "Upravit", "edit_location_dialog_title": "Poloha", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Zpět", "login_form_button_text": "Přihlásit se", "login_form_email_hint": "tvůje-mail@email.com", - "login_form_endpoint_hint": "http://ip-tvého-serveru:port/api", + "login_form_endpoint_hint": "http://ip-tvého-serveru:port", "login_form_endpoint_url": "URL adresa serveru", "login_form_err_http": "Prosím, uveďte http:// nebo https://", "login_form_err_invalid_email": "Neplatný e-mail", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Výrobce", "search_filter_camera_model": "Model", "search_filter_camera_title": "Výběr typu fotoaparátu", + "search_filter_contextual": "Vyhledávat podle kontextu", "search_filter_date": "Datum", "search_filter_date_interval": "{start} až {end}", "search_filter_date_title": "Výběr rozmezí dat", + "search_filter_description": "Vyhledávat podle popisu", "search_filter_display_option_archive": "Archiv", "search_filter_display_option_favorite": "Oblíbené", "search_filter_display_option_not_in_album": "Není v albu", "search_filter_display_options": "Možnost zobrazení", "search_filter_display_options_title": "Možnosti zobrazení", + "search_filter_filename": "Vyhledávat podle názvu souboru", "search_filter_location": "Poloha", "search_filter_location_city": "Město", "search_filter_location_country": "Země", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Výběr typu média", "search_filter_media_type_video": "Video", "search_filter_people": "Lidé", + "search_filter_people_hint": "Filtrovat lidi", "search_filter_people_title": "Výběr lidí", + "search_no_more_result": "Žádné další výsledky", + "search_no_result": "Nebyly nalezeny žádné výsledky, zkuste zadat jiný hledaný výraz nebo kombinaci", "search_page_categories": "Kategorie", "search_page_favorites": "Oblíbené", "search_page_motion_photos": "Pohyblivé fotky", diff --git a/mobile/assets/i18n/da-DK.json b/mobile/assets/i18n/da-DK.json index 27af06fe12..f278e2cff3 100644 --- a/mobile/assets/i18n/da-DK.json +++ b/mobile/assets/i18n/da-DK.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Slet delt link", "description_input_hint_text": "Tilføj en beskrivelse...", "description_input_submit_error": "Fejl med at opdatere beskrivelsen. Tjek loggen for flere detaljer", + "description_search": "Hiking day in Sapa", "download_canceled": "Download annulleret", "download_complete": "Download fuldført", "download_enqueue": "Donload sat i kø", @@ -247,6 +248,7 @@ "download_sucess_android": "Mediet er blevet downloadet til DCIM/Immich", "download_waiting_to_retry": "Afventer at prøve igen", "edit_date_time_dialog_date_time": "Dato og klokkeslæt", + "edit_date_time_dialog_search_timezone": "Search timezone...", "edit_date_time_dialog_timezone": "Tidszone", "edit_image_title": "Rediger", "edit_location_dialog_title": "Placering", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Tilbage", "login_form_button_text": "Log ind", "login_form_email_hint": "din-e-mail@e-mail.com", - "login_form_endpoint_hint": "http://din-server-ip:port/api", + "login_form_endpoint_hint": "http://din-server-ip:port", "login_form_endpoint_url": "Server Endpoint URL", "login_form_err_http": "Angiv venligst http:// eller https://", "login_form_err_invalid_email": "Ugyldig e-mail", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Producent", "search_filter_camera_model": "Model", "search_filter_camera_title": "Vælg type af kamera", + "search_filter_contextual": "Search by context", "search_filter_date": "Dato", "search_filter_date_interval": "{start} til { slut}", "search_filter_date_title": "Vælg et datointerval", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "Arkiv", "search_filter_display_option_favorite": "Favorit", "search_filter_display_option_not_in_album": "Ikke i album", "search_filter_display_options": "Visningsindstillinger", "search_filter_display_options_title": "Visningsindstillinger", + "search_filter_filename": "Search by file name", "search_filter_location": "Lokation", "search_filter_location_city": "By", "search_filter_location_country": "Land", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Vælg medietype", "search_filter_media_type_video": "Video", "search_filter_people": "Personer", + "search_filter_people_hint": "Filter people", "search_filter_people_title": "Vælg personer", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "Kategorier", "search_page_favorites": "Favoritter", "search_page_motion_photos": "Bevægelsesbilleder", diff --git a/mobile/assets/i18n/de-DE.json b/mobile/assets/i18n/de-DE.json index 87afe7fb7d..2ce979b5ef 100644 --- a/mobile/assets/i18n/de-DE.json +++ b/mobile/assets/i18n/de-DE.json @@ -157,8 +157,8 @@ "cache_settings_tile_title": "Lokaler Speicher", "cache_settings_title": "Zwischenspeicher Einstellungen", "cancel": "Abbrechen", - "canceled": "Canceled", - "change_display_order": "Change display order", + "canceled": "Abgebrochen", + "change_display_order": "Anzeigereihenfolge ändern", "change_password_form_confirm_password": "Passwort bestätigen", "change_password_form_description": "Hallo {name}\n\nDas ist entweder das erste Mal dass du dich einloggst oder es wurde eine Anfrage zur Änderung deines Passwortes gestellt. Bitte gib das neue Passwort ein.", "change_password_form_new_password": "Neues Passwort", @@ -181,7 +181,7 @@ "common_create_new_album": "Neues Album erstellen", "common_server_error": "Bitte überprüfe Deine Netzwerkverbindung und stelle sicher, dass die App und Server Versionen kompatibel sind.", "common_shared": "Geteilt", - "completed": "Completed", + "completed": "Fertig\n", "contextual_search": "Sonnenaufgang am Strand", "control_bottom_app_bar_add_to_album": "Zu Album hinzufügen", "control_bottom_app_bar_album_info": "{} Elemente", @@ -213,7 +213,7 @@ "crop": "Zuschneiden", "curated_location_page_title": "Orte", "curated_object_page_title": "Dinge", - "current_server_address": "Aktuelle Server-Adresse", + "current_server_address": "Aktuelle Serveradresse", "daily_title_text_date": "E, dd MMM", "daily_title_text_date_year": "E, dd MMM, yyyy", "date_format": "E d. LLL y • hh:mm", @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Geteilten Link löschen", "description_input_hint_text": "Beschreibung hinzufügen...", "description_input_submit_error": "Beschreibung konnte nicht geändert werden, bitte im Log für mehr Details nachsehen.", + "description_search": "Wandern in den Alpen", "download_canceled": "Download abgebrochen!", "download_complete": "Download vollständig!", "download_enqueue": "Download in die Warteschlange gesetzt!", @@ -247,13 +248,14 @@ "download_sucess_android": "Die Datei wurde nach DCIM/Immich heruntergeladen", "download_waiting_to_retry": "Warte auf erneuten Versuch...", "edit_date_time_dialog_date_time": "Datum und Uhrzeit", + "edit_date_time_dialog_search_timezone": "Zeitzone suchen...", "edit_date_time_dialog_timezone": "Zeitzone", "edit_image_title": "Bearbeiten", "edit_location_dialog_title": "Ort bearbeiten", - "end_date": "End date", - "enqueued": "Enqueued", + "end_date": "Enddatum", + "enqueued": "Eingereiht", "enter_wifi_name": "WLAN-Name eingeben", - "error_change_sort_album": "Failed to change album sort order", + "error_change_sort_album": "Ändern der Anzeigereihenfolge fehlgeschlagen", "error_saving_image": "Fehler: {}", "exif_bottom_sheet_description": "Beschreibung hinzufügen...", "exif_bottom_sheet_details": "DETAILS", @@ -267,7 +269,7 @@ "experimental_settings_title": "Experimentell", "external_network": "Externes Netzwerk", "external_network_sheet_info": "Wenn sich die App nicht im bevorzugten WLAN-Netzwerk befindet, verbindet sie sich mit dem Server über die erste der folgenden URLs, die sie erreichen kann (von oben nach unten)", - "failed": "Failed", + "failed": "Fehlgeschlagen", "favorites": "Favoriten", "favorites_page_no_favorites": "Keine favorisierten Inhalte gefunden", "favorites_page_title": "Favoriten", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Zurück", "login_form_button_text": "Anmelden", "login_form_email_hint": "deine@email.de", - "login_form_endpoint_hint": "http://deine-server-ip:port/api", + "login_form_endpoint_hint": "http://deine-server-ip:port", "login_form_endpoint_url": "Server-URL", "login_form_err_http": "Bitte gebe http:// oder https:// an", "login_form_err_invalid_email": "Ungültige E-Mail", @@ -403,7 +405,7 @@ "notification_permission_list_tile_content": "Erlaube Berechtigung für Benachrichtigungen", "notification_permission_list_tile_enable_button": "Aktiviere Benachrichtigungen", "notification_permission_list_tile_title": "Benachrichtigungs-Berechtigung", - "not_selected": "Not selected", + "not_selected": "Nicht ausgewählt", "on_this_device": "Auf diesem Gerät", "partner_list_user_photos": "{user}s Fotos", "partner_list_view_all": "Alle anzeigen", @@ -417,7 +419,7 @@ "partner_page_stop_sharing_title": "Deine Fotos nicht mehr teilen?", "partner_page_title": "Partner", "partners": "Partner", - "paused": "Paused", + "paused": "Pausiert", "people": "Personen", "permission_onboarding_back": "Zurück", "permission_onboarding_continue_anyway": "Trotzdem fortfahren", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Marke", "search_filter_camera_model": "Modell", "search_filter_camera_title": "Kameratyp auswählen ", + "search_filter_contextual": "Suche nach Kontext", "search_filter_date": "Datum", "search_filter_date_interval": "{start} bis {end}", "search_filter_date_title": "Wähle einen Zeitraum", + "search_filter_description": "Suche nach Beschreibung", "search_filter_display_option_archive": "Archiv", "search_filter_display_option_favorite": "Favorit", "search_filter_display_option_not_in_album": "Nicht im Album", "search_filter_display_options": "Anzeigeeinstellungen", "search_filter_display_options_title": "Anzeigeeinstellungen ", + "search_filter_filename": "Suche nach Dateiname", "search_filter_location": "Ort", "search_filter_location_city": "Stadt", "search_filter_location_country": "Land", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Medientyp auswählen ", "search_filter_media_type_video": "Video", "search_filter_people": "Personen", + "search_filter_people_hint": "Personen filtern", "search_filter_people_title": "Personen auswählen ", + "search_no_more_result": "Keine weiteren Ergebnisse", + "search_no_result": "Keine Ergebnisse gefunden, probiere eine andere Kombination oder einen anderen Suchbegriff", "search_page_categories": "Kategorien", "search_page_favorites": "Favoriten", "search_page_motion_photos": "Live-Fotos", @@ -534,8 +542,8 @@ "settings_require_restart": "Bitte starte Immich neu, um diese Einstellung anzuwenden.", "setting_video_viewer_looping_subtitle": "Aktiviere diese Option, um ein Video in der Detailansicht automatisch in einer Schleife anzuzeigen.", "setting_video_viewer_looping_title": "Schleife / Looping", - "setting_video_viewer_original_video_subtitle": "When streaming a video from the server, play the original even when a transcode is available. May lead to buffering. Videos available locally are played in original quality regardless of this setting.", - "setting_video_viewer_original_video_title": "Force original video", + "setting_video_viewer_original_video_subtitle": "Beim Streaming eines Videos vom Server wird das Original abgespielt, auch wenn eine Transkodierung verfügbar ist. Kann zu Pufferung führen. Lokal verfügbare Videos werden unabhängig von dieser Einstellung in Originalqualität wiedergegeben.", + "setting_video_viewer_original_video_title": "Originalvideo erzwingen", "setting_video_viewer_title": "Videos", "share_add": "Hinzufügen", "share_add_photos": "Fotos hinzufügen", @@ -554,7 +562,7 @@ "shared_album_section_people_owner_label": "Eigentümer", "shared_album_section_people_title": "PERSONEN", "share_dialog_preparing": "Vorbereiten...", - "shared_intent_upload_button_progress_text": "{} / {} Uploaded", + "shared_intent_upload_button_progress_text": "{} / {} hochgeladen", "shared_link_app_bar_title": "Geteilte Links", "shared_link_clipboard_copied_massage": "Link kopiert", "shared_link_clipboard_text": "Link: {}\nPasswort: {}", @@ -610,7 +618,7 @@ "sharing_silver_appbar_create_shared_album": "Neues geteiltes Album", "sharing_silver_appbar_shared_links": "Geteilte Links", "sharing_silver_appbar_share_partner": "Mit Partner teilen", - "start_date": "Start date", + "start_date": "Startdatum", "sync": "Synchronisieren", "sync_albums": "Alben synchronisieren", "sync_albums_manual_subtitle": "Synchronisiere alle hochgeladenen Videos und Fotos in die ausgewählten Backup-Alben", @@ -649,13 +657,13 @@ "trash_page_select_assets_btn": "Elemente auswählen", "trash_page_select_btn": "Auswählen", "trash_page_title": "Papierkorb ({})", - "upload": "Upload", + "upload": "Hochladen", "upload_dialog_cancel": "Abbrechen", "upload_dialog_info": "Willst du die ausgewählten Elemente auf dem Server sichern?", "upload_dialog_ok": "Hochladen", "upload_dialog_title": "Element hochladen", - "uploading": "Uploading", - "upload_to_immich": "Upload to Immich ({})", + "uploading": "Wird hochgeladen", + "upload_to_immich": "Zu Immich hochladen ({})", "use_current_connection": "aktuelle Verbindung verwenden", "validate_endpoint_error": "Bitte gib eine gültige URL ein", "version_announcement_overlay_ack": "Ich habe verstanden", diff --git a/mobile/assets/i18n/el-GR.json b/mobile/assets/i18n/el-GR.json index cde962b4b2..ca4032a6a9 100644 --- a/mobile/assets/i18n/el-GR.json +++ b/mobile/assets/i18n/el-GR.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Διαγραφή Κοινοποιημένου Συνδέσμου", "description_input_hint_text": "Προσθήκη περιγραφής...", "description_input_submit_error": "Σφάλμα κατά την ενημέρωση της περιγραφής, ελέγξτε το αρχείο καταγραφής για περισσότερες λεπτομέρειες", + "description_search": "Hiking day in Sapa", "download_canceled": "Η λήψη ακυρώθηκε", "download_complete": "Η λήψη ολοκληρώθηκε", "download_enqueue": "Η λήψη τέθηκε σε ουρά", @@ -247,6 +248,7 @@ "download_sucess_android": "Το μέσο έχει ληφθεί στο DCIM/Immich", "download_waiting_to_retry": "Αναμονή για επανάληψη", "edit_date_time_dialog_date_time": "Ημερομηνία και Ώρα", + "edit_date_time_dialog_search_timezone": "Search timezone...", "edit_date_time_dialog_timezone": "Ζώνη ώρας", "edit_image_title": "Επεξεργασία", "edit_location_dialog_title": "Τοποθεσία", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Πίσω", "login_form_button_text": "Σύνδεση", "login_form_email_hint": "to-email-sou@email.com", - "login_form_endpoint_hint": "http://ip-tou-server-sou:porta/api", + "login_form_endpoint_hint": "http://ip-tou-server-sou:porta", "login_form_endpoint_url": "URL τελικού σημείου διακομιστή", "login_form_err_http": "Προσδιορίστε http:// ή https://", "login_form_err_invalid_email": "Μη έγκυρο email", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Μάρκα", "search_filter_camera_model": "Μοντέλο", "search_filter_camera_title": "Επιλέξτε τύπο κάμερας", + "search_filter_contextual": "Search by context", "search_filter_date": "Ημερομηνία", "search_filter_date_interval": "{start} έως {end}", "search_filter_date_title": "Επιλέξτε εύρος ημερομηνιών", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "Αρχείο", "search_filter_display_option_favorite": "Αγαπημένο", "search_filter_display_option_not_in_album": "Όχι στο άλμπουμ", "search_filter_display_options": "Επιλογές εμφάνισης", "search_filter_display_options_title": "Επιλογές εμφάνισης", + "search_filter_filename": "Search by file name", "search_filter_location": "Τοποθεσία", "search_filter_location_city": "Πόλη", "search_filter_location_country": "Χώρα", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Επιλέξτε τύπο μέσου", "search_filter_media_type_video": "Βίντεο", "search_filter_people": "Ανθρωποι", + "search_filter_people_hint": "Filter people", "search_filter_people_title": "Επιλέξτε άτομα", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "Κατηγορίες", "search_page_favorites": "Αγαπημένα", "search_page_motion_photos": "Κινούμενες Φωτογραφίες", diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index f91f5842db..bd2397ce3b 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Delete Shared Link", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", + "description_search": "Hiking day in Sapa", "download_canceled": "Download canceled", "download_complete": "Download complete", "download_enqueue": "Download enqueued", @@ -247,9 +248,11 @@ "download_sucess_android": "The media has been downloaded to DCIM/Immich", "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Date and Time", + "edit_date_time_dialog_search_timezone": "Search timezone...", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", "edit_location_dialog_title": "Location", + "empty_folder": "This folder is empty", "end_date": "End date", "enqueued": "Enqueued", "enter_wifi_name": "Enter WiFi name", @@ -261,6 +264,7 @@ "exif_bottom_sheet_location_add": "Add a location", "exif_bottom_sheet_people": "PEOPLE", "exif_bottom_sheet_person_add_person": "Add name", + "exif_bottom_sheet_person_age": "Age {}", "experimental_settings_new_asset_list_subtitle": "Work in progress", "experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_subtitle": "Use at your own risk!", @@ -273,6 +277,11 @@ "favorites_page_title": "Favorites", "filename_search": "File name or extension", "filter": "Filter", + "folders": "Folders", + "folder": "Folder", + "failed_to_load_folder": "Failed to load folder", + "failed_to_load_assets": "Failed to load assets", + "folder_not_found": "Folder not found", "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", "grant_permission": "Grant permission", "haptic_feedback_switch": "Enable haptic feedback", @@ -336,7 +345,7 @@ "login_form_back_button_text": "Back", "login_form_button_text": "Login", "login_form_email_hint": "youremail@email.com", - "login_form_endpoint_hint": "http://your-server-ip:port/api", + "login_form_endpoint_hint": "http://your-server-ip:port", "login_form_endpoint_url": "Server Endpoint URL", "login_form_err_http": "Please specify http:// or https://", "login_form_err_invalid_email": "Invalid Email", @@ -455,14 +464,17 @@ "search_filter_camera_make": "Make", "search_filter_camera_model": "Model", "search_filter_camera_title": "Select camera type", + "search_filter_contextual": "Search by context", "search_filter_date": "Date", "search_filter_date_interval": "{start} to {end}", "search_filter_date_title": "Select a date range", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "Archive", "search_filter_display_option_favorite": "Favorite", "search_filter_display_option_not_in_album": "Not in album", "search_filter_display_options": "Display Options", "search_filter_display_options_title": "Display options", + "search_filter_filename": "Search by file name", "search_filter_location": "Location", "search_filter_location_city": "City", "search_filter_location_country": "Country", @@ -474,7 +486,10 @@ "search_filter_media_type_title": "Select media type", "search_filter_media_type_video": "Video", "search_filter_people": "People", + "search_filter_people_hint": "Filter people", "search_filter_people_title": "Select people", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "Categories", "search_page_favorites": "Favorites", "search_page_motion_photos": "Motion Photos", @@ -670,4 +685,4 @@ "viewer_unstack": "Un-Stack", "wifi_name": "WiFi Name", "your_wifi_name": "Your WiFi name" -} \ No newline at end of file +} diff --git a/mobile/assets/i18n/es-ES.json b/mobile/assets/i18n/es-ES.json index 04df71de55..c232dfaea5 100644 --- a/mobile/assets/i18n/es-ES.json +++ b/mobile/assets/i18n/es-ES.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Eliminar enlace compartido", "description_input_hint_text": "Agregar descripción...", "description_input_submit_error": "Error al actualizar la descripción, verifica el registro para obtener más detalles", + "description_search": "Hiking day in Sapa", "download_canceled": "Descarga cancelada", "download_complete": "Descarga completada", "download_enqueue": "Descarga en cola", @@ -247,6 +248,7 @@ "download_sucess_android": "Los archivos se han descargado en DCIM/Immich", "download_waiting_to_retry": "Esperando para reintentar", "edit_date_time_dialog_date_time": "Fecha y Hora", + "edit_date_time_dialog_search_timezone": "Search timezone...", "edit_date_time_dialog_timezone": "Zona horaria", "edit_image_title": "Editar", "edit_location_dialog_title": "Ubicación", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Atrás", "login_form_button_text": "Iniciar Sesión", "login_form_email_hint": "tucorreo@correo.com", - "login_form_endpoint_hint": "http://tu-ip-de-servidor:puerto/api", + "login_form_endpoint_hint": "http://tu-ip-de-servidor:puerto", "login_form_endpoint_url": "URL del servidor", "login_form_err_http": "Por favor, especifique http:// o https://", "login_form_err_invalid_email": "Correo electrónico no válido", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Marca", "search_filter_camera_model": "Modelo", "search_filter_camera_title": "Elige tipo de cámara", + "search_filter_contextual": "Search by context", "search_filter_date": "Fecha", "search_filter_date_interval": "{start} al {end}", "search_filter_date_title": "Selecciona un intervalo de fechas", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "Archivado", "search_filter_display_option_favorite": "Favorito", "search_filter_display_option_not_in_album": "No en álbum", "search_filter_display_options": "Display Options", "search_filter_display_options_title": "Display options", + "search_filter_filename": "Search by file name", "search_filter_location": "Ubicación", "search_filter_location_city": "Ciudad", "search_filter_location_country": "País", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Selecciona el tipo de archivo", "search_filter_media_type_video": "Vídeo", "search_filter_people": "Personas", + "search_filter_people_hint": "Filter people", "search_filter_people_title": "Seleccionar personas", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "Categorías", "search_page_favorites": "Favoritos", "search_page_motion_photos": "Foto en Movimiento", diff --git a/mobile/assets/i18n/es-MX.json b/mobile/assets/i18n/es-MX.json index 51e946aa0e..f8222edb92 100644 --- a/mobile/assets/i18n/es-MX.json +++ b/mobile/assets/i18n/es-MX.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Eliminar enlace compartido", "description_input_hint_text": "Agregar descripción...", "description_input_submit_error": "Error al actualizar la descripción, verifica el registro para obtener más detalles", + "description_search": "Hiking day in Sapa", "download_canceled": "Download canceled", "download_complete": "Download complete", "download_enqueue": "Download enqueued", @@ -247,6 +248,7 @@ "download_sucess_android": "The media has been downloaded to DCIM/Immich", "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Date and Time", + "edit_date_time_dialog_search_timezone": "Search timezone...", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", "edit_location_dialog_title": "Location", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Back", "login_form_button_text": "Iniciar sesión", "login_form_email_hint": "tucorreo@correo.com", - "login_form_endpoint_hint": "http://la-ip-de-tu-servidor:puerto/api", + "login_form_endpoint_hint": "http://la-ip-de-tu-servidor:puerto", "login_form_endpoint_url": "URL del servidor", "login_form_err_http": "Por favor, especifique http:// o https://", "login_form_err_invalid_email": "Correo electrónico inválido", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Marca", "search_filter_camera_model": "Modelo", "search_filter_camera_title": "Elige tipo de cámara", + "search_filter_contextual": "Search by context", "search_filter_date": "Fecha", "search_filter_date_interval": "{start} to {end}", "search_filter_date_title": "Select a date range", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "Archive", "search_filter_display_option_favorite": "Favorite", "search_filter_display_option_not_in_album": "Not in album", "search_filter_display_options": "Display Options", "search_filter_display_options_title": "Display options", + "search_filter_filename": "Search by file name", "search_filter_location": "Ubicación", "search_filter_location_city": "City", "search_filter_location_country": "Country", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Selecciona el tipo de archivo", "search_filter_media_type_video": "Video", "search_filter_people": "Personas", + "search_filter_people_hint": "Filter people", "search_filter_people_title": "Seleccionar personas", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "Categorías", "search_page_favorites": "Favoritos", "search_page_motion_photos": "Foto en Movimiento", diff --git a/mobile/assets/i18n/es-PE.json b/mobile/assets/i18n/es-PE.json index 9e461e96e3..2d0816f4de 100644 --- a/mobile/assets/i18n/es-PE.json +++ b/mobile/assets/i18n/es-PE.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Eliminar enlace compartido", "description_input_hint_text": "Agregar descripción...", "description_input_submit_error": "Error al actualizar la descripción, verifica el registro para obtener más detalles", + "description_search": "Hiking day in Sapa", "download_canceled": "Download canceled", "download_complete": "Download complete", "download_enqueue": "Download enqueued", @@ -247,6 +248,7 @@ "download_sucess_android": "The media has been downloaded to DCIM/Immich", "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Date and Time", + "edit_date_time_dialog_search_timezone": "Search timezone...", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", "edit_location_dialog_title": "Location", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Back", "login_form_button_text": "Iniciar sesión", "login_form_email_hint": "tucorreo@correo.com", - "login_form_endpoint_hint": "http://la-ip-de-tu-servidor:puerto/api", + "login_form_endpoint_hint": "http://la-ip-de-tu-servidor:puerto", "login_form_endpoint_url": "URL del servidor", "login_form_err_http": "Por favor, especifique http:// o https://", "login_form_err_invalid_email": "Correo electrónico inválido", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Make", "search_filter_camera_model": "Model", "search_filter_camera_title": "Select camera type", + "search_filter_contextual": "Search by context", "search_filter_date": "Date", "search_filter_date_interval": "{start} to {end}", "search_filter_date_title": "Select a date range", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "Archive", "search_filter_display_option_favorite": "Favorite", "search_filter_display_option_not_in_album": "Not in album", "search_filter_display_options": "Display Options", "search_filter_display_options_title": "Display options", + "search_filter_filename": "Search by file name", "search_filter_location": "Location", "search_filter_location_city": "City", "search_filter_location_country": "Country", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Select media type", "search_filter_media_type_video": "Video", "search_filter_people": "People", + "search_filter_people_hint": "Filter people", "search_filter_people_title": "Select people", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "Categorías", "search_page_favorites": "Favoritos", "search_page_motion_photos": "Foto en Movimiento", diff --git a/mobile/assets/i18n/es-US.json b/mobile/assets/i18n/es-US.json index 28b2ae28fc..b46131664e 100644 --- a/mobile/assets/i18n/es-US.json +++ b/mobile/assets/i18n/es-US.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Eliminar enlace compartido", "description_input_hint_text": "Agregar descripción...", "description_input_submit_error": "Error al actualizar la descripción, verifica el registro para obtener más detalles", + "description_search": "Hiking day in Sapa", "download_canceled": "Download canceled", "download_complete": "Download complete", "download_enqueue": "Download enqueued", @@ -247,6 +248,7 @@ "download_sucess_android": "The media has been downloaded to DCIM/Immich", "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Date and Time", + "edit_date_time_dialog_search_timezone": "Search timezone...", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", "edit_location_dialog_title": "Location", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Back", "login_form_button_text": "Iniciar sesión", "login_form_email_hint": "tucorreo@correo.com", - "login_form_endpoint_hint": "http://ip-de-tu-servidor:puerto/api", + "login_form_endpoint_hint": "http://ip-de-tu-servidor:puerto", "login_form_endpoint_url": "URL del servidor", "login_form_err_http": "Por favor, especifique http:// o https://", "login_form_err_invalid_email": "Correo electrónico inválido", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Marca", "search_filter_camera_model": "Modelo", "search_filter_camera_title": "Elige tipo de cámara", + "search_filter_contextual": "Search by context", "search_filter_date": "Fecha", "search_filter_date_interval": "{start} to {end}", "search_filter_date_title": "Select a date range", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "Archive", "search_filter_display_option_favorite": "Favorite", "search_filter_display_option_not_in_album": "Not in album", "search_filter_display_options": "Display Options", "search_filter_display_options_title": "Display options", + "search_filter_filename": "Search by file name", "search_filter_location": "Ubicación", "search_filter_location_city": "City", "search_filter_location_country": "Country", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Selecciona el tipo de archivo", "search_filter_media_type_video": "Video", "search_filter_people": "Personas", + "search_filter_people_hint": "Filter people", "search_filter_people_title": "Seleccionar personas", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "Categorías", "search_page_favorites": "Favoritos", "search_page_motion_photos": "Fotos en .ovimiento", diff --git a/mobile/assets/i18n/fi-FI.json b/mobile/assets/i18n/fi-FI.json index 7a53415fea..2909df7bf1 100644 --- a/mobile/assets/i18n/fi-FI.json +++ b/mobile/assets/i18n/fi-FI.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Poista jaettu linkki", "description_input_hint_text": "Lisää kuvaus...", "description_input_submit_error": "Virhe kuvauksen päivittämisessä, tarkista lisätiedot lokista", + "description_search": "Hiking day in Sapa", "download_canceled": "Download canceled", "download_complete": "Download complete", "download_enqueue": "Download enqueued", @@ -247,6 +248,7 @@ "download_sucess_android": "The media has been downloaded to DCIM/Immich", "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Päivämäärä ja aika", + "edit_date_time_dialog_search_timezone": "Search timezone...", "edit_date_time_dialog_timezone": "Aikavyöhyke", "edit_image_title": "Edit", "edit_location_dialog_title": "Sijainti", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Takaisin", "login_form_button_text": "Kirjaudu", "login_form_email_hint": "sahkopostisi@esimerkki.fi", - "login_form_endpoint_hint": "http://palvelimesi-osoite:portti/api", + "login_form_endpoint_hint": "http://palvelimesi-osoite:portti", "login_form_endpoint_url": "Palvelimen URL", "login_form_err_http": "Lisää http:// tai https://", "login_form_err_invalid_email": "Virheellinen sähköpostiosoite", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Valmistaja", "search_filter_camera_model": "Malli", "search_filter_camera_title": "Select camera type", + "search_filter_contextual": "Search by context", "search_filter_date": "Date", "search_filter_date_interval": "{start} to {end}", "search_filter_date_title": "Select a date range", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "Arkisto", "search_filter_display_option_favorite": "Suosikki", "search_filter_display_option_not_in_album": "Ei kuulu albumiin", "search_filter_display_options": "Display Options", "search_filter_display_options_title": "Display options", + "search_filter_filename": "Search by file name", "search_filter_location": "Location", "search_filter_location_city": "Kaupunki", "search_filter_location_country": "Maa", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Select media type", "search_filter_media_type_video": "Video", "search_filter_people": "People", + "search_filter_people_hint": "Filter people", "search_filter_people_title": "Select people", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "Kategoriat", "search_page_favorites": "Suosikit", "search_page_motion_photos": "Liikekuvat", diff --git a/mobile/assets/i18n/fr-CA.json b/mobile/assets/i18n/fr-CA.json index 52752106f9..f143279fb4 100644 --- a/mobile/assets/i18n/fr-CA.json +++ b/mobile/assets/i18n/fr-CA.json @@ -167,7 +167,7 @@ "check_corrupt_asset_backup": "Check for corrupt asset backups", "check_corrupt_asset_backup_button": "Perform check", "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", - "client_cert_dialog_msg_confirm": "OK", + "client_cert_dialog_msg_confirm": "D'accord", "client_cert_enter_password": "Enter Password", "client_cert_import": "Import", "client_cert_import_success_msg": "Client certificate is imported", @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Supprimer le lien partagé", "description_input_hint_text": "Ajouter une description...", "description_input_submit_error": "Erreur de mise à jour de la description, vérifier le journal pour plus de détails", + "description_search": "Randonnée dans les Laurentides", "download_canceled": "Download canceled", "download_complete": "Download complete", "download_enqueue": "Download enqueued", @@ -247,6 +248,7 @@ "download_sucess_android": "The media has been downloaded to DCIM/Immich", "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Date and Time", + "edit_date_time_dialog_search_timezone": "Search timezone...", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", "edit_location_dialog_title": "Location", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Back", "login_form_button_text": "Connexion", "login_form_email_hint": "votrecourriel@email.com", - "login_form_endpoint_hint": "http://adresse-ip-serveur:port/api", + "login_form_endpoint_hint": "http://adresse-ip-serveur:port", "login_form_endpoint_url": "URL du point d'accès au serveur", "login_form_err_http": "Veuillez préciser http:// ou https://", "login_form_err_invalid_email": "Courriel invalide", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Make", "search_filter_camera_model": "Model", "search_filter_camera_title": "Select camera type", + "search_filter_contextual": "Search by context", "search_filter_date": "Date", "search_filter_date_interval": "{start} to {end}", "search_filter_date_title": "Select a date range", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "Archive", "search_filter_display_option_favorite": "Favorite", "search_filter_display_option_not_in_album": "Not in album", "search_filter_display_options": "Display Options", "search_filter_display_options_title": "Display options", + "search_filter_filename": "Search by file name", "search_filter_location": "Location", "search_filter_location_city": "City", "search_filter_location_country": "Country", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Select media type", "search_filter_media_type_video": "Video", "search_filter_people": "People", + "search_filter_people_hint": "Filter people", "search_filter_people_title": "Select people", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "Catégories", "search_page_favorites": "Favoris", "search_page_motion_photos": "Photos avec mouvement", diff --git a/mobile/assets/i18n/fr-FR.json b/mobile/assets/i18n/fr-FR.json index 0ac72b692b..9cf1148c99 100644 --- a/mobile/assets/i18n/fr-FR.json +++ b/mobile/assets/i18n/fr-FR.json @@ -157,8 +157,8 @@ "cache_settings_tile_title": "Stockage local", "cache_settings_title": "Paramètres de mise en cache", "cancel": "Annuler", - "canceled": "Canceled", - "change_display_order": "Change display order", + "canceled": "Annulé", + "change_display_order": "Modifier l'ordre d'affichage", "change_password_form_confirm_password": "Confirmez le mot de passe", "change_password_form_description": "Bonjour {name},\n\nC'est la première fois que vous vous connectez au système ou vous avez demandé à changer votre mot de passe. Veuillez saisir le nouveau mot de passe ci-dessous.", "change_password_form_new_password": "Nouveau mot de passe", @@ -181,7 +181,7 @@ "common_create_new_album": "Créer un nouvel album", "common_server_error": "Veuillez vérifier votre connexion réseau, vous assurer que le serveur est accessible et que les versions de l'application et du serveur sont compatibles.", "common_shared": "Partagé", - "completed": "Completed", + "completed": "Complété", "contextual_search": "Lever de soleil sur la plage", "control_bottom_app_bar_add_to_album": "Ajouter à l'album", "control_bottom_app_bar_album_info": "{} éléments", @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Supprimer le lien partagé", "description_input_hint_text": "Ajouter une description…", "description_input_submit_error": "Erreur de mise à jour de la description, vérifier le journal pour plus de détails", + "description_search": "Randonnée en Provence", "download_canceled": "Téléchargement annulé", "download_complete": "Téléchargement terminé", "download_enqueue": "Téléchargement en attente", @@ -247,13 +248,14 @@ "download_sucess_android": "Le média a été téléchargé dans DCIM/Immich", "download_waiting_to_retry": "Téléchargement en attente du prochain essai", "edit_date_time_dialog_date_time": "Date et heure", + "edit_date_time_dialog_search_timezone": "Rechercher le fuseau horaire...", "edit_date_time_dialog_timezone": "Fuseau horaire", "edit_image_title": "Modifier", "edit_location_dialog_title": "Localisation", - "end_date": "End date", - "enqueued": "Enqueued", + "end_date": "Date de fin", + "enqueued": "Mis en file", "enter_wifi_name": "Entrez le nom du réseau ", - "error_change_sort_album": "Failed to change album sort order", + "error_change_sort_album": "Impossible de modifier l'ordre de tri des albums", "error_saving_image": "Erreur : {}", "exif_bottom_sheet_description": "Ajouter une description…", "exif_bottom_sheet_details": "DÉTAILS", @@ -267,7 +269,7 @@ "experimental_settings_title": "Expérimental", "external_network": "Réseau externe", "external_network_sheet_info": "Quand vous n'êtes pas connecté à votre réseau préféré, l'application va tenter de se connecter aux adresses ci-dessous, en commençant par la première", - "failed": "Failed", + "failed": "Échec", "favorites": "Favoris", "favorites_page_no_favorites": "Aucun élément favori n'a été trouvé", "favorites_page_title": "Favoris", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Retour", "login_form_button_text": "Connexion", "login_form_email_hint": "votreemail@email.com", - "login_form_endpoint_hint": "http://adresse-ip-serveur:port/api", + "login_form_endpoint_hint": "http://adresse-ip-serveur:port", "login_form_endpoint_url": "URL du point d'accès au serveur", "login_form_err_http": "Veuillez préciser http:// ou https://", "login_form_err_invalid_email": "E-mail invalide", @@ -403,7 +405,7 @@ "notification_permission_list_tile_content": "Accordez la permission d'activer les notifications.", "notification_permission_list_tile_enable_button": "Activer les notifications", "notification_permission_list_tile_title": "Permission de notification", - "not_selected": "Not selected", + "not_selected": "Non sélectionné", "on_this_device": "Sur cet appareil", "partner_list_user_photos": "Photos de {user}", "partner_list_view_all": "Voir tous", @@ -417,7 +419,7 @@ "partner_page_stop_sharing_title": "Arrêter de partager vos photos ?", "partner_page_title": "Partenaire", "partners": "Partenaires", - "paused": "Paused", + "paused": "En pause", "people": "Personnes", "permission_onboarding_back": "Retour", "permission_onboarding_continue_anyway": "Continuer quand même", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Fabricant", "search_filter_camera_model": "Modèle", "search_filter_camera_title": "Sélectionner le type d'appareil", + "search_filter_contextual": "Recherche contextuelle", "search_filter_date": "Date", "search_filter_date_interval": "{start} à {end}", "search_filter_date_title": "Sélectionner une période", + "search_filter_description": "Recherche par description", "search_filter_display_option_archive": "Archivé", "search_filter_display_option_favorite": "Favoris", "search_filter_display_option_not_in_album": "Pas dans un album", "search_filter_display_options": "Options d'affichage", "search_filter_display_options_title": "Options d'affichage", + "search_filter_filename": "Recherche par nom de fichier", "search_filter_location": "Lieu", "search_filter_location_city": "Ville", "search_filter_location_country": "Pays", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Sélectionner type de média", "search_filter_media_type_video": "Vidéo", "search_filter_people": "Personnes", + "search_filter_people_hint": "Filtrer par personne", "search_filter_people_title": "Sélectionner une personne", + "search_no_more_result": "\nPlus de résultats", + "search_no_result": "Aucun résultat trouvé, essayez un autre terme de recherche ou une autre combinaison", "search_page_categories": "Catégories", "search_page_favorites": "Favoris", "search_page_motion_photos": "Photos animées", @@ -534,8 +542,8 @@ "settings_require_restart": "Veuillez redémarrer Immich pour appliquer ce paramètre", "setting_video_viewer_looping_subtitle": "Activer pour voir les vidéos en boucle dans le lecteur détaillé", "setting_video_viewer_looping_title": "Boucle", - "setting_video_viewer_original_video_subtitle": "When streaming a video from the server, play the original even when a transcode is available. May lead to buffering. Videos available locally are played in original quality regardless of this setting.", - "setting_video_viewer_original_video_title": "Force original video", + "setting_video_viewer_original_video_subtitle": "Lors de la diffusion d'une vidéo depuis le serveur, lisez l'original même si un transcodage est disponible. Cela peut entraîner de la mise en mémoire tampon. Les vidéos disponibles localement sont lues en qualité d'origine, quel que soit ce paramètre.", + "setting_video_viewer_original_video_title": "Forcer la vidéo originale", "setting_video_viewer_title": "Vidéos", "share_add": "Ajouter", "share_add_photos": "Ajouter des photos", @@ -554,7 +562,7 @@ "shared_album_section_people_owner_label": "Propriétaire", "shared_album_section_people_title": "PERSONNES", "share_dialog_preparing": "Préparation…", - "shared_intent_upload_button_progress_text": "{} / {} Uploaded", + "shared_intent_upload_button_progress_text": "{} / {} Téléversé", "shared_link_app_bar_title": "Liens partagés", "shared_link_clipboard_copied_massage": "Copié dans le presse-papier", "shared_link_clipboard_text": "Lien : {}\nMot de passe : {}", @@ -610,7 +618,7 @@ "sharing_silver_appbar_create_shared_album": "Créer un album partagé", "sharing_silver_appbar_shared_links": "Liens partagés", "sharing_silver_appbar_share_partner": "Partager avec un partenaire", - "start_date": "Start date", + "start_date": "Date de début", "sync": "Synchroniser", "sync_albums": "Synchroniser dans des albums", "sync_albums_manual_subtitle": "Synchroniser toutes les vidéos et photos sauvegardées dans les albums sélectionnés", @@ -649,13 +657,13 @@ "trash_page_select_assets_btn": "Sélectionner les éléments", "trash_page_select_btn": "Sélectionner", "trash_page_title": "Corbeille ({})", - "upload": "Upload", + "upload": "Téléverser", "upload_dialog_cancel": "Annuler", "upload_dialog_info": "Voulez-vous sauvegarder la sélection vers le serveur ?", "upload_dialog_ok": "Télécharger ", "upload_dialog_title": "Télécharger cet élément ", - "uploading": "Uploading", - "upload_to_immich": "Upload to Immich ({})", + "uploading": "Téléversement en cours", + "upload_to_immich": "Téléverser vers Immich ({})", "use_current_connection": "Utiliser le réseau actuel ", "validate_endpoint_error": "Merci d'entrer un lien valide", "version_announcement_overlay_ack": "Confirmer", diff --git a/mobile/assets/i18n/ga.json b/mobile/assets/i18n/ga.json new file mode 100644 index 0000000000..fd628d4692 --- /dev/null +++ b/mobile/assets/i18n/ga.json @@ -0,0 +1,681 @@ +{ + "action_common_back": "Back", + "action_common_cancel": "Cancel", + "action_common_clear": "Clear", + "action_common_confirm": "Confirm", + "action_common_save": "Save", + "action_common_select": "Select", + "action_common_update": "Update", + "add_a_name": "Add a name", + "add_endpoint": "Add endpoint", + "add_to_album_bottom_sheet_added": "Added to {album}", + "add_to_album_bottom_sheet_already_exists": "Already in {album}", + "advanced_settings_log_level_title": "Log level: {}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", + "advanced_settings_proxy_headers_subtitle": "Define proxy headers Immich should send with each network request", + "advanced_settings_proxy_headers_title": "Proxy Headers", + "advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.", + "advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates", + "advanced_settings_tile_subtitle": "Advanced user's settings", + "advanced_settings_tile_title": "Advanced", + "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", + "advanced_settings_troubleshooting_title": "Troubleshooting", + "album_info_card_backup_album_excluded": "EXCLUDED", + "album_info_card_backup_album_included": "INCLUDED", + "albums": "Albums", + "album_thumbnail_card_item": "1 item", + "album_thumbnail_card_items": "{} items", + "album_thumbnail_card_shared": " · Shared", + "album_thumbnail_owned": "Owned", + "album_thumbnail_shared_by": "Shared by {}", + "album_viewer_appbar_delete_confirm": "Are you sure you want to delete this album from your account?", + "album_viewer_appbar_share_delete": "Delete album", + "album_viewer_appbar_share_err_delete": "Failed to delete album", + "album_viewer_appbar_share_err_leave": "Failed to leave album", + "album_viewer_appbar_share_err_remove": "There are problems in removing assets from album", + "album_viewer_appbar_share_err_title": "Failed to change album title", + "album_viewer_appbar_share_leave": "Leave album", + "album_viewer_appbar_share_remove": "Remove from album", + "album_viewer_appbar_share_to": "Share To", + "album_viewer_page_share_add_users": "Add users", + "all": "All", + "all_people_page_title": "People", + "all_videos_page_title": "Videos", + "app_bar_signout_dialog_content": "Are you sure you want to sign out?", + "app_bar_signout_dialog_ok": "Yes", + "app_bar_signout_dialog_title": "Sign out", + "archived": "Archived", + "archive_page_no_archived_assets": "No archived assets found", + "archive_page_title": "Archive ({})", + "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", + "asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping", + "asset_list_group_by_sub_title": "Group by", + "asset_list_layout_settings_dynamic_layout_title": "Dynamic layout", + "asset_list_layout_settings_group_automatically": "Automatic", + "asset_list_layout_settings_group_by": "Group assets by", + "asset_list_layout_settings_group_by_month": "Month", + "asset_list_layout_settings_group_by_month_day": "Month + day", + "asset_list_layout_sub_title": "Layout", + "asset_list_settings_subtitle": "Photo grid layout settings", + "asset_list_settings_title": "Photo Grid", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", + "asset_viewer_settings_title": "Asset Viewer", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", + "backup_album_selection_page_albums_device": "Albums on device ({})", + "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", + "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", + "backup_album_selection_page_select_albums": "Select albums", + "backup_album_selection_page_selection_info": "Selection Info", + "backup_album_selection_page_total_assets": "Total unique assets", + "backup_all": "All", + "backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…", + "backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…", + "backup_background_service_current_upload_notification": "Uploading {}", + "backup_background_service_default_notification": "Checking for new assets…", + "backup_background_service_error_title": "Backup error", + "backup_background_service_in_progress_notification": "Backing up your assets…", + "backup_background_service_upload_failure_notification": "Failed to upload {}", + "backup_controller_page_albums": "Backup Albums", + "backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.", + "backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled", + "backup_controller_page_background_app_refresh_enable_button_text": "Go to settings", + "backup_controller_page_background_battery_info_link": "Show me how", + "backup_controller_page_background_battery_info_message": "For the best background backup experience, please disable any battery optimizations restricting background activity for Immich.\n\nSince this is device-specific, please lookup the required information for your device manufacturer.", + "backup_controller_page_background_battery_info_ok": "OK", + "backup_controller_page_background_battery_info_title": "Battery optimizations", + "backup_controller_page_background_charging": "Only while charging", + "backup_controller_page_background_configure_error": "Failed to configure the background service", + "backup_controller_page_background_delay": "Delay new assets backup: {}", + "backup_controller_page_background_description": "Turn on the background service to automatically backup any new assets without needing to open the app", + "backup_controller_page_background_is_off": "Automatic background backup is off", + "backup_controller_page_background_is_on": "Automatic background backup is on", + "backup_controller_page_background_turn_off": "Turn off background service", + "backup_controller_page_background_turn_on": "Turn on background service", + "backup_controller_page_background_wifi": "Only on WiFi", + "backup_controller_page_backup": "Backup", + "backup_controller_page_backup_selected": "Selected: ", + "backup_controller_page_backup_sub": "Backed up photos and videos", + "backup_controller_page_cancel": "Cancel", + "backup_controller_page_created": "Created on: {}", + "backup_controller_page_desc_backup": "Turn on foreground backup to automatically upload new assets to the server when opening the app.", + "backup_controller_page_excluded": "Excluded: ", + "backup_controller_page_failed": "Failed ({})", + "backup_controller_page_filename": "File name: {} [{}]", + "backup_controller_page_id": "ID: {}", + "backup_controller_page_info": "Backup Information", + "backup_controller_page_none_selected": "None selected", + "backup_controller_page_remainder": "Remainder", + "backup_controller_page_remainder_sub": "Remaining photos and videos to back up from selection", + "backup_controller_page_select": "Select", + "backup_controller_page_server_storage": "Server Storage", + "backup_controller_page_start_backup": "Start Backup", + "backup_controller_page_status_off": "Automatic foreground backup is off", + "backup_controller_page_status_on": "Automatic foreground backup is on", + "backup_controller_page_storage_format": "{} of {} used", + "backup_controller_page_to_backup": "Albums to be backed up", + "backup_controller_page_total": "Total", + "backup_controller_page_total_sub": "All unique photos and videos from selected albums", + "backup_controller_page_turn_off": "Turn off foreground backup", + "backup_controller_page_turn_on": "Turn on foreground backup", + "backup_controller_page_uploading_file_info": "Uploading file info", + "backup_err_only_album": "Cannot remove the only album", + "backup_info_card_assets": "assets", + "backup_manual_cancelled": "Cancelled", + "backup_manual_failed": "Failed", + "backup_manual_in_progress": "Upload already in progress. Try after sometime", + "backup_manual_success": "Success", + "backup_manual_title": "Upload status", + "backup_options_page_title": "Backup options", + "backup_setting_subtitle": "Manage background and foreground upload settings", + "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", + "cache_settings_clear_cache_button": "Clear cache", + "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.", + "cache_settings_duplicated_assets_clear_button": "CLEAR", + "cache_settings_duplicated_assets_subtitle": "Photos and videos that are black listed by the app", + "cache_settings_duplicated_assets_title": "Duplicated Assets ({})", + "cache_settings_image_cache_size": "Image cache size ({} assets)", + "cache_settings_statistics_album": "Library thumbnails", + "cache_settings_statistics_assets": "{} assets ({})", + "cache_settings_statistics_full": "Full images", + "cache_settings_statistics_shared": "Shared album thumbnails", + "cache_settings_statistics_thumbnail": "Thumbnails", + "cache_settings_statistics_title": "Cache usage", + "cache_settings_subtitle": "Control the caching behaviour of the Immich mobile application", + "cache_settings_thumbnail_size": "Thumbnail cache size ({} assets)", + "cache_settings_tile_subtitle": "Control the local storage behaviour", + "cache_settings_tile_title": "Local Storage", + "cache_settings_title": "Caching Settings", + "cancel": "Cancel", + "canceled": "Canceled", + "change_display_order": "Change display order", + "change_password_form_confirm_password": "Confirm Password", + "change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", + "change_password_form_new_password": "New Password", + "change_password_form_password_mismatch": "Passwords do not match", + "change_password_form_reenter_new_password": "Re-enter New Password", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", + "client_cert_dialog_msg_confirm": "OK", + "client_cert_enter_password": "Enter Password", + "client_cert_import": "Import", + "client_cert_import_success_msg": "Client certificate is imported", + "client_cert_invalid_msg": "Invalid certificate file or wrong password", + "client_cert_remove": "Remove", + "client_cert_remove_msg": "Client certificate is removed", + "client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate Import/Remove is available only before login", + "client_cert_title": "SSL Client Certificate", + "common_add_to_album": "Add to album", + "common_change_password": "Change Password", + "common_create_new_album": "Create new album", + "common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.", + "common_shared": "Shared", + "completed": "Completed", + "contextual_search": "Sunrise on the beach", + "control_bottom_app_bar_add_to_album": "Add to album", + "control_bottom_app_bar_album_info": "{} items", + "control_bottom_app_bar_album_info_shared": "{} items · Shared", + "control_bottom_app_bar_archive": "Archive", + "control_bottom_app_bar_create_new_album": "Create new album", + "control_bottom_app_bar_delete": "Delete", + "control_bottom_app_bar_delete_from_immich": "Delete from Immich", + "control_bottom_app_bar_delete_from_local": "Delete from device", + "control_bottom_app_bar_download": "Download", + "control_bottom_app_bar_edit": "Edit", + "control_bottom_app_bar_edit_location": "Edit Location", + "control_bottom_app_bar_edit_time": "Edit Date & Time", + "control_bottom_app_bar_favorite": "Favorite", + "control_bottom_app_bar_share": "Share", + "control_bottom_app_bar_share_to": "Share To", + "control_bottom_app_bar_stack": "Stack", + "control_bottom_app_bar_trash_from_immich": "Move to Trash", + "control_bottom_app_bar_unarchive": "Unarchive", + "control_bottom_app_bar_unfavorite": "Unfavorite", + "control_bottom_app_bar_upload": "Upload", + "create_album": "Create album", + "create_album_page_untitled": "Untitled", + "create_new": "CREATE NEW", + "create_shared_album_page_create": "Create", + "create_shared_album_page_share": "Share", + "create_shared_album_page_share_add_assets": "ADD ASSETS", + "create_shared_album_page_share_select_photos": "Select Photos", + "crop": "Crop", + "curated_location_page_title": "Places", + "curated_object_page_title": "Things", + "current_server_address": "Current server address", + "daily_title_text_date": "E, MMM dd", + "daily_title_text_date_year": "E, MMM dd, yyyy", + "date_format": "E, LLL d, y • h:mm a", + "delete_dialog_alert": "These items will be permanently deleted from Immich and from your device", + "delete_dialog_alert_local": "These items will be permanently removed from your device but still be available on the Immich server", + "delete_dialog_alert_local_non_backed_up": "Some of the items aren't backed up to Immich and will be permanently removed from your device", + "delete_dialog_alert_remote": "These items will be permanently deleted from the Immich server", + "delete_dialog_cancel": "Cancel", + "delete_dialog_ok": "Delete", + "delete_dialog_ok_force": "Delete Anyway", + "delete_dialog_title": "Delete Permanently", + "delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only", + "delete_local_dialog_ok_force": "Delete Anyway", + "delete_shared_link_dialog_content": "Are you sure you want to delete this shared link?", + "delete_shared_link_dialog_title": "Delete Shared Link", + "description_input_hint_text": "Add description...", + "description_input_submit_error": "Error updating description, check the log for more details", + "description_search": "Hiking day in Sapa", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", + "download_error": "Download Error", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", + "edit_date_time_dialog_date_time": "Date and Time", + "edit_date_time_dialog_search_timezone": "Search timezone...", + "edit_date_time_dialog_timezone": "Timezone", + "edit_image_title": "Edit", + "edit_location_dialog_title": "Location", + "end_date": "End date", + "enqueued": "Enqueued", + "enter_wifi_name": "Enter WiFi name", + "error_change_sort_album": "Failed to change album sort order", + "error_saving_image": "Error: {}", + "exif_bottom_sheet_description": "Add Description...", + "exif_bottom_sheet_details": "DETAILS", + "exif_bottom_sheet_location": "LOCATION", + "exif_bottom_sheet_location_add": "Add a location", + "exif_bottom_sheet_people": "PEOPLE", + "exif_bottom_sheet_person_add_person": "Add name", + "experimental_settings_new_asset_list_subtitle": "Work in progress", + "experimental_settings_new_asset_list_title": "Enable experimental photo grid", + "experimental_settings_subtitle": "Use at your own risk!", + "experimental_settings_title": "Experimental", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", + "failed": "Failed", + "favorites": "Favorites", + "favorites_page_no_favorites": "No favorite assets found", + "favorites_page_title": "Favorites", + "filename_search": "File name or extension", + "filter": "Filter", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", + "haptic_feedback_switch": "Enable haptic feedback", + "haptic_feedback_title": "Haptic Feedback", + "header_settings_add_header_tip": "Add Header", + "header_settings_field_validator_msg": "Value cannot be empty", + "header_settings_header_name_input": "Header name", + "header_settings_header_value_input": "Header value", + "header_settings_page_title": "Proxy Headers", + "headers_settings_tile_subtitle": "Define proxy headers the app should send with each network request", + "headers_settings_tile_title": "Custom proxy headers", + "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", + "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", + "home_page_add_to_album_success": "Added {added} assets to album {album}.", + "home_page_album_err_partner": "Can not add partner assets to an album yet, skipping", + "home_page_archive_err_local": "Can not archive local assets yet, skipping", + "home_page_archive_err_partner": "Can not archive partner assets, skipping", + "home_page_building_timeline": "Building the timeline", + "home_page_delete_err_partner": "Can not delete partner assets, skipping", + "home_page_delete_remote_err_local": "Local assets in delete remote selection, skipping", + "home_page_favorite_err_local": "Can not favorite local assets yet, skipping", + "home_page_favorite_err_partner": "Can not favorite partner assets yet, skipping", + "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", + "home_page_share_err_local": "Can not share local assets via link, skipping", + "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", + "image_saved_successfully": "Image saved", + "image_viewer_page_state_provider_download_error": "Download Error", + "image_viewer_page_state_provider_download_started": "Download Started", + "image_viewer_page_state_provider_download_success": "Download Success", + "image_viewer_page_state_provider_share_error": "Share Error", + "invalid_date": "Invalid date", + "invalid_date_format": "Invalid date format", + "library": "Library", + "library_page_albums": "Albums", + "library_page_archive": "Archive", + "library_page_device_albums": "Albums on Device", + "library_page_favorites": "Favorites", + "library_page_new_album": "New album", + "library_page_sharing": "Sharing", + "library_page_sort_asset_count": "Number of assets", + "library_page_sort_created": "Created date", + "library_page_sort_last_modified": "Last modified", + "library_page_sort_most_oldest_photo": "Oldest photo", + "library_page_sort_most_recent_photo": "Most recent photo", + "library_page_sort_title": "Album title", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", + "location_picker_choose_on_map": "Choose on map", + "location_picker_latitude": "Latitude", + "location_picker_latitude_error": "Enter a valid latitude", + "location_picker_latitude_hint": "Enter your latitude here", + "location_picker_longitude": "Longitude", + "location_picker_longitude_error": "Enter a valid longitude", + "location_picker_longitude_hint": "Enter your longitude here", + "login_disabled": "Login has been disabled", + "login_form_api_exception": "API exception. Please check the server URL and try again.", + "login_form_back_button_text": "Back", + "login_form_button_text": "Login", + "login_form_email_hint": "youremail@email.com", + "login_form_endpoint_hint": "http://your-server-ip:port", + "login_form_endpoint_url": "Server Endpoint URL", + "login_form_err_http": "Please specify http:// or https://", + "login_form_err_invalid_email": "Invalid Email", + "login_form_err_invalid_url": "Invalid URL", + "login_form_err_leading_whitespace": "Leading whitespace", + "login_form_err_trailing_whitespace": "Trailing whitespace", + "login_form_failed_get_oauth_server_config": "Error logging using OAuth, check server URL", + "login_form_failed_get_oauth_server_disable": "OAuth feature is not available on this server", + "login_form_failed_login": "Error logging you in, check server URL, email and password", + "login_form_handshake_exception": "There was an Handshake Exception with the server. Enable self-signed certificate support in the settings if you are using a self-signed certificate.", + "login_form_label_email": "Email", + "login_form_label_password": "Password", + "login_form_next_button": "Next", + "login_form_password_hint": "password", + "login_form_save_login": "Stay logged in", + "login_form_server_empty": "Enter a server URL.", + "login_form_server_error": "Could not connect to server.", + "login_password_changed_error": "There was an error updating your password", + "login_password_changed_success": "Password updated successfully", + "map_assets_in_bound": "{} photo", + "map_assets_in_bounds": "{} photos", + "map_cannot_get_user_location": "Cannot get user's location", + "map_location_dialog_cancel": "Cancel", + "map_location_dialog_yes": "Yes", + "map_location_picker_page_use_location": "Use this location", + "map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?", + "map_location_service_disabled_title": "Location Service disabled", + "map_no_assets_in_bounds": "No photos in this area", + "map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?", + "map_no_location_permission_title": "Location Permission denied", + "map_settings_dark_mode": "Dark mode", + "map_settings_date_range_option_all": "All", + "map_settings_date_range_option_day": "Past 24 hours", + "map_settings_date_range_option_days": "Past {} days", + "map_settings_date_range_option_year": "Past year", + "map_settings_date_range_option_years": "Past {} years", + "map_settings_dialog_cancel": "Cancel", + "map_settings_dialog_save": "Save", + "map_settings_dialog_title": "Map Settings", + "map_settings_include_show_archived": "Include Archived", + "map_settings_include_show_partners": "Include Partners", + "map_settings_only_relative_range": "Date range", + "map_settings_only_show_favorites": "Show Favorite Only", + "map_settings_theme_settings": "Map Theme", + "map_zoom_to_see_photos": "Zoom out to see photos", + "memories_all_caught_up": "All caught up", + "memories_check_back_tomorrow": "Check back tomorrow for more memories", + "memories_start_over": "Start Over", + "memories_swipe_to_close": "Swipe up to close", + "memories_year_ago": "A year ago", + "memories_years_ago": "{} years ago", + "monthly_title_text_date_format": "MMMM y", + "motion_photos_page_title": "Motion Photos", + "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", + "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "my_albums": "My albums", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", + "no_assets_to_show": "No assets to show", + "no_name": "No name", + "notification_permission_dialog_cancel": "Cancel", + "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", + "notification_permission_dialog_settings": "Settings", + "notification_permission_list_tile_content": "Grant permission to enable notifications.", + "notification_permission_list_tile_enable_button": "Enable Notifications", + "notification_permission_list_tile_title": "Notification Permission", + "not_selected": "Not selected", + "on_this_device": "On this device", + "partner_list_user_photos": "{user}'s photos", + "partner_list_view_all": "View all", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", + "partners": "Partners", + "paused": "Paused", + "people": "People", + "permission_onboarding_back": "Back", + "permission_onboarding_continue_anyway": "Continue anyway", + "permission_onboarding_get_started": "Get started", + "permission_onboarding_go_to_settings": "Go to settings", + "permission_onboarding_grant_permission": "Grant permission", + "permission_onboarding_log_out": "Log out", + "permission_onboarding_permission_denied": "Permission denied. To use Immich, grant photo and video permissions in Settings.", + "permission_onboarding_permission_granted": "Permission granted! You are all set.", + "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", + "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "places": "Places", + "preferences_settings_subtitle": "Manage the app's preferences", + "preferences_settings_title": "Preferences", + "profile_drawer_app_logs": "Logs", + "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", + "profile_drawer_client_out_of_date_minor": "Mobile App is out of date. Please update to the latest minor version.", + "profile_drawer_client_server_up_to_date": "Client and Server are up-to-date", + "profile_drawer_documentation": "Documentation", + "profile_drawer_github": "GitHub", + "profile_drawer_server_out_of_date_major": "Server is out of date. Please update to the latest major version.", + "profile_drawer_server_out_of_date_minor": "Server is out of date. Please update to the latest minor version.", + "profile_drawer_settings": "Settings", + "profile_drawer_sign_out": "Sign Out", + "profile_drawer_trash": "Trash", + "recently_added": "Recently added", + "recently_added_page_title": "Recently Added", + "save": "Save", + "save_to_gallery": "Save to gallery", + "scaffold_body_error_occurred": "Error occurred", + "search_albums": "Search albums", + "search_bar_hint": "Search your photos", + "search_filter_apply": "Apply filter", + "search_filter_camera": "Camera", + "search_filter_camera_make": "Make", + "search_filter_camera_model": "Model", + "search_filter_camera_title": "Select camera type", + "search_filter_contextual": "Search by context", + "search_filter_date": "Date", + "search_filter_date_interval": "{start} to {end}", + "search_filter_date_title": "Select a date range", + "search_filter_description": "Search by description", + "search_filter_display_option_archive": "Archive", + "search_filter_display_option_favorite": "Favorite", + "search_filter_display_option_not_in_album": "Not in album", + "search_filter_display_options": "Display Options", + "search_filter_display_options_title": "Display options", + "search_filter_filename": "Search by file name", + "search_filter_location": "Location", + "search_filter_location_city": "City", + "search_filter_location_country": "Country", + "search_filter_location_state": "State", + "search_filter_location_title": "Select location", + "search_filter_media_type": "Media Type", + "search_filter_media_type_all": "All", + "search_filter_media_type_image": "Image", + "search_filter_media_type_title": "Select media type", + "search_filter_media_type_video": "Video", + "search_filter_people": "People", + "search_filter_people_hint": "Filter people", + "search_filter_people_title": "Select people", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", + "search_page_categories": "Categories", + "search_page_favorites": "Favorites", + "search_page_motion_photos": "Motion Photos", + "search_page_no_objects": "No Objects Info Available", + "search_page_no_places": "No Places Info Available", + "search_page_people": "People", + "search_page_person_add_name_dialog_cancel": "Cancel", + "search_page_person_add_name_dialog_hint": "Name", + "search_page_person_add_name_dialog_save": "Save", + "search_page_person_add_name_dialog_title": "Add a name", + "search_page_person_add_name_subtitle": "Find them fast by name with search", + "search_page_person_add_name_title": "Add a name", + "search_page_person_edit_name": "Edit name", + "search_page_places": "Places", + "search_page_recently_added": "Recently added", + "search_page_screenshots": "Screenshots", + "search_page_search_photos_videos": "Search for your photos and videos", + "search_page_selfies": "Selfies", + "search_page_things": "Things", + "search_page_videos": "Videos", + "search_page_view_all_button": "View all", + "search_page_your_activity": "Your activity", + "search_page_your_map": "Your Map", + "search_result_page_new_search_hint": "New Search", + "search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ", + "search_suggestion_list_smart_search_hint_2": "m:your-search-term", + "select_additional_user_for_sharing_page_suggestions": "Suggestions", + "select_user_for_sharing_page_err_album": "Failed to create album", + "select_user_for_sharing_page_share_suggestions": "Suggestions", + "server_endpoint": "Server Endpoint", + "server_info_box_app_version": "App Version", + "server_info_box_latest_release": "Latest Version", + "server_info_box_server_url": "Server URL", + "server_info_box_server_version": "Server Version", + "setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).", + "setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).", + "setting_image_viewer_original_title": "Load original image", + "setting_image_viewer_preview_subtitle": "Enable to load a medium-resolution image. Disable to either directly load the original or only use the thumbnail.", + "setting_image_viewer_preview_title": "Load preview image", + "setting_image_viewer_title": "Images", + "setting_languages_apply": "Apply", + "setting_languages_subtitle": "Change the app's language", + "setting_languages_title": "Languages", + "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}", + "setting_notifications_notify_hours": "{} hours", + "setting_notifications_notify_immediately": "immediately", + "setting_notifications_notify_minutes": "{} minutes", + "setting_notifications_notify_never": "never", + "setting_notifications_notify_seconds": "{} seconds", + "setting_notifications_single_progress_subtitle": "Detailed upload progress information per asset", + "setting_notifications_single_progress_title": "Show background backup detail progress", + "setting_notifications_subtitle": "Adjust your notification preferences", + "setting_notifications_title": "Notifications", + "setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)", + "setting_notifications_total_progress_title": "Show background backup total progress", + "setting_pages_app_bar_settings": "Settings", + "settings_require_restart": "Please restart Immich to apply this setting", + "setting_video_viewer_looping_subtitle": "Enable to automatically loop a video in the detail viewer.", + "setting_video_viewer_looping_title": "Looping", + "setting_video_viewer_original_video_subtitle": "When streaming a video from the server, play the original even when a transcode is available. May lead to buffering. Videos available locally are played in original quality regardless of this setting.", + "setting_video_viewer_original_video_title": "Force original video", + "setting_video_viewer_title": "Videos", + "share_add": "Add", + "share_add_photos": "Add photos", + "share_add_title": "Add a title", + "share_assets_selected": "{} selected", + "share_create_album": "Create album", + "shared_album_activities_input_disable": "Comment is disabled", + "shared_album_activities_input_hint": "Say something", + "shared_album_activity_remove_content": "Do you want to delete this activity?", + "shared_album_activity_remove_title": "Delete Activity", + "shared_album_activity_setting_subtitle": "Let others respond", + "shared_album_activity_setting_title": "Comments & likes", + "shared_album_section_people_action_error": "Error leaving/removing from album", + "shared_album_section_people_action_leave": "Remove user from album", + "shared_album_section_people_action_remove_user": "Remove user from album", + "shared_album_section_people_owner_label": "Owner", + "shared_album_section_people_title": "PEOPLE", + "share_dialog_preparing": "Preparing...", + "shared_intent_upload_button_progress_text": "{} / {} Uploaded", + "shared_link_app_bar_title": "Shared Links", + "shared_link_clipboard_copied_massage": "Copied to clipboard", + "shared_link_clipboard_text": "Link: {}\nPassword: {}", + "shared_link_create_app_bar_title": "Create link to share", + "shared_link_create_error": "Error while creating shared link", + "shared_link_create_info": "Let anyone with the link see the selected photo(s)", + "shared_link_create_submit_button": "Create link", + "shared_link_edit_allow_download": "Allow public user to download", + "shared_link_edit_allow_upload": "Allow public user to upload", + "shared_link_edit_app_bar_title": "Edit link", + "shared_link_edit_change_expiry": "Change expiration time", + "shared_link_edit_description": "Description", + "shared_link_edit_description_hint": "Enter the share description", + "shared_link_edit_expire_after": "Expire after", + "shared_link_edit_expire_after_option_day": "1 day", + "shared_link_edit_expire_after_option_days": "{} days", + "shared_link_edit_expire_after_option_hour": "1 hour", + "shared_link_edit_expire_after_option_hours": "{} hours", + "shared_link_edit_expire_after_option_minute": "1 minute", + "shared_link_edit_expire_after_option_minutes": "{} minutes", + "shared_link_edit_expire_after_option_months": "{} months", + "shared_link_edit_expire_after_option_never": "Never", + "shared_link_edit_expire_after_option_year": "{} year", + "shared_link_edit_password": "Password", + "shared_link_edit_password_hint": "Enter the share password", + "shared_link_edit_show_meta": "Show metadata", + "shared_link_edit_submit_button": "Update link", + "shared_link_empty": "You don't have any shared links", + "shared_link_error_server_url_fetch": "Cannot fetch the server url", + "shared_link_expired": "Expired", + "shared_link_expires_day": "Expires in {} day", + "shared_link_expires_days": "Expires in {} days", + "shared_link_expires_hour": "Expires in {} hour", + "shared_link_expires_hours": "Expires in {} hours", + "shared_link_expires_minute": "Expires in {} minute", + "shared_link_expires_minutes": "Expires in {} minutes", + "shared_link_expires_never": "Expires ∞", + "shared_link_expires_second": "Expires in {} second", + "shared_link_expires_seconds": "Expires in {} seconds", + "shared_link_individual_shared": "Individual shared", + "shared_link_info_chip_download": "Download", + "shared_link_info_chip_metadata": "EXIF", + "shared_link_info_chip_upload": "Upload", + "shared_link_manage_links": "Manage Shared links", + "shared_link_public_album": "Public album", + "shared_links": "Shared links", + "share_done": "Done", + "shared_with_me": "Shared with me", + "share_invite": "Invite to album", + "sharing_page_album": "Shared albums", + "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", + "sharing_page_empty_list": "EMPTY LIST", + "sharing_silver_appbar_create_shared_album": "New shared album", + "sharing_silver_appbar_shared_links": "Shared links", + "sharing_silver_appbar_share_partner": "Share with partner", + "start_date": "Start date", + "sync": "Sync", + "sync_albums": "Sync albums", + "sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums", + "sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich", + "tab_controller_nav_library": "Library", + "tab_controller_nav_photos": "Photos", + "tab_controller_nav_search": "Search", + "tab_controller_nav_sharing": "Sharing", + "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", + "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", + "theme_setting_dark_mode_switch": "Dark mode", + "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", + "theme_setting_image_viewer_quality_title": "Image viewer quality", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", + "theme_setting_system_theme_switch": "Automatic (Follow system setting)", + "theme_setting_theme_subtitle": "Choose the app's theme setting", + "theme_setting_theme_title": "Theme", + "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", + "theme_setting_three_stage_loading_title": "Enable three-stage loading", + "translated_text_options": "Options", + "trash": "Trash", + "trash_emptied": "Emptied trash", + "trash_page_delete": "Delete", + "trash_page_delete_all": "Delete All", + "trash_page_empty_trash_btn": "Empty trash", + "trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich", + "trash_page_empty_trash_dialog_ok": "Ok", + "trash_page_info": "Trashed items will be permanently deleted after {} days", + "trash_page_no_assets": "No trashed assets", + "trash_page_restore": "Restore", + "trash_page_restore_all": "Restore All", + "trash_page_select_assets_btn": "Select assets", + "trash_page_select_btn": "Select", + "trash_page_title": "Trash ({})", + "upload": "Upload", + "upload_dialog_cancel": "Cancel", + "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", + "upload_dialog_ok": "Upload", + "upload_dialog_title": "Upload Asset", + "uploading": "Uploading", + "upload_to_immich": "Upload to Immich ({})", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", + "version_announcement_overlay_ack": "Acknowledge", + "version_announcement_overlay_release_notes": "release notes", + "version_announcement_overlay_text_1": "Hi friend, there is a new release of", + "version_announcement_overlay_text_2": "please take your time to visit the ", + "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", + "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", + "videos": "Videos", + "viewer_remove_from_stack": "Remove from Stack", + "viewer_stack_use_as_main_asset": "Use as Main Asset", + "viewer_unstack": "Un-Stack", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" +} \ No newline at end of file diff --git a/mobile/assets/i18n/gl-ES.json b/mobile/assets/i18n/gl-ES.json new file mode 100644 index 0000000000..a5b43d7447 --- /dev/null +++ b/mobile/assets/i18n/gl-ES.json @@ -0,0 +1,673 @@ +{ + "action_common_back": "Back", + "action_common_cancel": "Cancel", + "action_common_clear": "Clear", + "action_common_confirm": "Confirm", + "action_common_save": "Save", + "action_common_select": "Select", + "action_common_update": "Update", + "add_a_name": "Add a name", + "add_endpoint": "Add endpoint", + "add_to_album_bottom_sheet_added": "Added to {album}", + "add_to_album_bottom_sheet_already_exists": "Already in {album}", + "advanced_settings_log_level_title": "Log level: {}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", + "advanced_settings_proxy_headers_subtitle": "Define proxy headers Immich should send with each network request", + "advanced_settings_proxy_headers_title": "Proxy Headers", + "advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.", + "advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates (EXPERIMENTAL)", + "advanced_settings_tile_subtitle": "Advanced user's settings", + "advanced_settings_tile_title": "Advanced", + "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", + "advanced_settings_troubleshooting_title": "Troubleshooting", + "album_info_card_backup_album_excluded": "EXCLUDED", + "album_info_card_backup_album_included": "INCLUDED", + "albums": "Albums", + "album_thumbnail_card_item": "1 item", + "album_thumbnail_card_items": "{} items", + "album_thumbnail_card_shared": " · Shared", + "album_thumbnail_owned": "Owned", + "album_thumbnail_shared_by": "Shared by {}", + "album_viewer_appbar_delete_confirm": "Are you sure you want to delete this album from your account?", + "album_viewer_appbar_share_delete": "Delete album", + "album_viewer_appbar_share_err_delete": "Failed to delete album", + "album_viewer_appbar_share_err_leave": "Failed to leave album", + "album_viewer_appbar_share_err_remove": "There are problems in removing assets from album", + "album_viewer_appbar_share_err_title": "Failed to change album title", + "album_viewer_appbar_share_leave": "Leave album", + "album_viewer_appbar_share_remove": "Remove from album", + "album_viewer_appbar_share_to": "Share To", + "album_viewer_page_share_add_users": "Add users", + "all": "All", + "all_people_page_title": "People", + "all_videos_page_title": "Videos", + "app_bar_signout_dialog_content": "Are you sure you want to sign out?", + "app_bar_signout_dialog_ok": "Yes", + "app_bar_signout_dialog_title": "Sign out", + "archived": "Archived", + "archive_page_no_archived_assets": "No archived assets found", + "archive_page_title": "Archive ({})", + "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", + "asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping", + "asset_list_group_by_sub_title": "Group by", + "asset_list_layout_settings_dynamic_layout_title": "Dynamic layout", + "asset_list_layout_settings_group_automatically": "Automatic", + "asset_list_layout_settings_group_by": "Group assets by", + "asset_list_layout_settings_group_by_month": "Month", + "asset_list_layout_settings_group_by_month_day": "Month + day", + "asset_list_layout_sub_title": "Layout", + "asset_list_settings_subtitle": "Photo grid layout settings", + "asset_list_settings_title": "Photo Grid", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", + "asset_viewer_settings_title": "Asset Viewer", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", + "backup_album_selection_page_albums_device": "Albums on device ({})", + "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", + "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", + "backup_album_selection_page_select_albums": "Select albums", + "backup_album_selection_page_selection_info": "Selection Info", + "backup_album_selection_page_total_assets": "Total unique assets", + "backup_all": "All", + "backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…", + "backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…", + "backup_background_service_current_upload_notification": "Uploading {}", + "backup_background_service_default_notification": "Checking for new assets…", + "backup_background_service_error_title": "Backup error", + "backup_background_service_in_progress_notification": "Backing up your assets…", + "backup_background_service_upload_failure_notification": "Failed to upload {}", + "backup_controller_page_albums": "Backup Albums", + "backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.", + "backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled", + "backup_controller_page_background_app_refresh_enable_button_text": "Go to settings", + "backup_controller_page_background_battery_info_link": "Show me how", + "backup_controller_page_background_battery_info_message": "For the best background backup experience, please disable any battery optimizations restricting background activity for Immich.\n\nSince this is device-specific, please lookup the required information for your device manufacturer.", + "backup_controller_page_background_battery_info_ok": "OK", + "backup_controller_page_background_battery_info_title": "Battery optimizations", + "backup_controller_page_background_charging": "Only while charging", + "backup_controller_page_background_configure_error": "Failed to configure the background service", + "backup_controller_page_background_delay": "Delay new assets backup: {}", + "backup_controller_page_background_description": "Turn on the background service to automatically backup any new assets without needing to open the app", + "backup_controller_page_background_is_off": "Automatic background backup is off", + "backup_controller_page_background_is_on": "Automatic background backup is on", + "backup_controller_page_background_turn_off": "Turn off background service", + "backup_controller_page_background_turn_on": "Turn on background service", + "backup_controller_page_background_wifi": "Only on WiFi", + "backup_controller_page_backup": "Backup", + "backup_controller_page_backup_selected": "Selected: ", + "backup_controller_page_backup_sub": "Backed up photos and videos", + "backup_controller_page_cancel": "Cancel", + "backup_controller_page_created": "Created on: {}", + "backup_controller_page_desc_backup": "Turn on foreground backup to automatically upload new assets to the server when opening the app.", + "backup_controller_page_excluded": "Excluded: ", + "backup_controller_page_failed": "Failed ({})", + "backup_controller_page_filename": "File name: {} [{}]", + "backup_controller_page_id": "ID: {}", + "backup_controller_page_info": "Backup Information", + "backup_controller_page_none_selected": "None selected", + "backup_controller_page_remainder": "Remainder", + "backup_controller_page_remainder_sub": "Remaining photos and videos to back up from selection", + "backup_controller_page_select": "Select", + "backup_controller_page_server_storage": "Server Storage", + "backup_controller_page_start_backup": "Start Backup", + "backup_controller_page_status_off": "Automatic foreground backup is off", + "backup_controller_page_status_on": "Automatic foreground backup is on", + "backup_controller_page_storage_format": "{} of {} used", + "backup_controller_page_to_backup": "Albums to be backed up", + "backup_controller_page_total": "Total", + "backup_controller_page_total_sub": "All unique photos and videos from selected albums", + "backup_controller_page_turn_off": "Turn off foreground backup", + "backup_controller_page_turn_on": "Turn on foreground backup", + "backup_controller_page_uploading_file_info": "Uploading file info", + "backup_err_only_album": "Cannot remove the only album", + "backup_info_card_assets": "assets", + "backup_manual_cancelled": "Cancelled", + "backup_manual_failed": "Failed", + "backup_manual_in_progress": "Upload already in progress. Try after some time", + "backup_manual_success": "Success", + "backup_manual_title": "Upload status", + "backup_options_page_title": "Backup options", + "backup_setting_subtitle": "Manage background and foreground upload settings", + "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", + "cache_settings_clear_cache_button": "Clear cache", + "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.", + "cache_settings_duplicated_assets_clear_button": "CLEAR", + "cache_settings_duplicated_assets_subtitle": "Photos and videos that are black listed by the app", + "cache_settings_duplicated_assets_title": "Duplicated Assets ({})", + "cache_settings_image_cache_size": "Image cache size ({} assets)", + "cache_settings_statistics_album": "Library thumbnails", + "cache_settings_statistics_assets": "{} assets ({})", + "cache_settings_statistics_full": "Full images", + "cache_settings_statistics_shared": "Shared album thumbnails", + "cache_settings_statistics_thumbnail": "Thumbnails", + "cache_settings_statistics_title": "Cache usage", + "cache_settings_subtitle": "Control the caching behaviour of the Immich mobile application", + "cache_settings_thumbnail_size": "Thumbnail cache size ({} assets)", + "cache_settings_tile_subtitle": "Control the local storage behaviour", + "cache_settings_tile_title": "Local Storage", + "cache_settings_title": "Caching Settings", + "cancel": "Cancel", + "canceled": "Canceled", + "change_display_order": "Change display order", + "change_password_form_confirm_password": "Confirm Password", + "change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", + "change_password_form_new_password": "New Password", + "change_password_form_password_mismatch": "Passwords do not match", + "change_password_form_reenter_new_password": "Re-enter New Password", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", + "client_cert_dialog_msg_confirm": "OK", + "client_cert_enter_password": "Enter Password", + "client_cert_import": "Import", + "client_cert_import_success_msg": "Client certificate is imported", + "client_cert_invalid_msg": "Invalid certificate file or wrong password", + "client_cert_remove": "Remove", + "client_cert_remove_msg": "Client certificate is removed", + "client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate Import/Remove is available only before login", + "client_cert_title": "SSL Client Certificate (EXPERIMENTAL)", + "common_add_to_album": "Add to album", + "common_change_password": "Change Password", + "common_create_new_album": "Create new album", + "common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.", + "common_shared": "Shared", + "completed": "Completed", + "contextual_search": "Sunrise on the beach", + "control_bottom_app_bar_add_to_album": "Add to album", + "control_bottom_app_bar_album_info": "{} items", + "control_bottom_app_bar_album_info_shared": "{} items · Shared", + "control_bottom_app_bar_archive": "Archive", + "control_bottom_app_bar_create_new_album": "Create new album", + "control_bottom_app_bar_delete": "Delete", + "control_bottom_app_bar_delete_from_immich": "Delete from Immich", + "control_bottom_app_bar_delete_from_local": "Delete from device", + "control_bottom_app_bar_download": "Download", + "control_bottom_app_bar_edit": "Edit", + "control_bottom_app_bar_edit_location": "Edit Location", + "control_bottom_app_bar_edit_time": "Edit Date & Time", + "control_bottom_app_bar_favorite": "Favorite", + "control_bottom_app_bar_share": "Share", + "control_bottom_app_bar_share_to": "Share To", + "control_bottom_app_bar_stack": "Stack", + "control_bottom_app_bar_trash_from_immich": "Move to Trash", + "control_bottom_app_bar_unarchive": "Unarchive", + "control_bottom_app_bar_unfavorite": "Unfavorite", + "control_bottom_app_bar_upload": "Upload", + "create_album": "Create album", + "create_album_page_untitled": "Untitled", + "create_new": "CREATE NEW", + "create_shared_album_page_create": "Create", + "create_shared_album_page_share": "Share", + "create_shared_album_page_share_add_assets": "ADD ASSETS", + "create_shared_album_page_share_select_photos": "Select Photos", + "crop": "Crop", + "curated_location_page_title": "Places", + "curated_object_page_title": "Things", + "current_server_address": "Current server address", + "daily_title_text_date": "E, MMM dd", + "daily_title_text_date_year": "E, MMM dd, yyyy", + "date_format": "E, LLL d, y • h:mm a", + "delete_dialog_alert": "These items will be permanently deleted from Immich and from your device", + "delete_dialog_alert_local": "These items will be permanently removed from your device but still be available on the Immich server", + "delete_dialog_alert_local_non_backed_up": "Some of the items aren't backed up to Immich and will be permanently removed from your device", + "delete_dialog_alert_remote": "These items will be permanently deleted from the Immich server", + "delete_dialog_cancel": "Cancel", + "delete_dialog_ok": "Delete", + "delete_dialog_ok_force": "Delete Anyway", + "delete_dialog_title": "Delete Permanently", + "delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only", + "delete_local_dialog_ok_force": "Delete Anyway", + "delete_shared_link_dialog_content": "Are you sure you want to delete this shared link?", + "delete_shared_link_dialog_title": "Delete Shared Link", + "description_input_hint_text": "Add description...", + "description_input_submit_error": "Error updating description, check the log for more details", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", + "download_error": "Download Error", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", + "edit_date_time_dialog_date_time": "Date and Time", + "edit_date_time_dialog_timezone": "Timezone", + "edit_image_title": "Edit", + "edit_location_dialog_title": "Location", + "end_date": "End date", + "enqueued": "Enqueued", + "enter_wifi_name": "Enter WiFi name", + "error_change_sort_album": "Failed to change album sort order", + "error_saving_image": "Error: {}", + "exif_bottom_sheet_description": "Add Description...", + "exif_bottom_sheet_details": "DETAILS", + "exif_bottom_sheet_location": "LOCATION", + "exif_bottom_sheet_location_add": "Add a location", + "exif_bottom_sheet_people": "PEOPLE", + "exif_bottom_sheet_person_add_person": "Add name", + "experimental_settings_new_asset_list_subtitle": "Work in progress", + "experimental_settings_new_asset_list_title": "Enable experimental photo grid", + "experimental_settings_subtitle": "Use at your own risk!", + "experimental_settings_title": "Experimental", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", + "failed": "Failed", + "favorites": "Favorites", + "favorites_page_no_favorites": "No favorite assets found", + "favorites_page_title": "Favorites", + "filename_search": "File name or extension", + "filter": "Filter", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", + "haptic_feedback_switch": "Enable haptic feedback", + "haptic_feedback_title": "Haptic Feedback", + "header_settings_add_header_tip": "Add Header", + "header_settings_field_validator_msg": "Value cannot be empty", + "header_settings_header_name_input": "Header name", + "header_settings_header_value_input": "Header value", + "header_settings_page_title": "Proxy Headers (EXPERIMENTAL)", + "headers_settings_tile_subtitle": "Define proxy headers the app should send with each network request", + "headers_settings_tile_title": "Custom proxy headers", + "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", + "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", + "home_page_add_to_album_success": "Added {added} assets to album {album}.", + "home_page_album_err_partner": "Can not add partner assets to an album yet, skipping", + "home_page_archive_err_local": "Can not archive local assets yet, skipping", + "home_page_archive_err_partner": "Can not archive partner assets, skipping", + "home_page_building_timeline": "Building the timeline", + "home_page_delete_err_partner": "Can not delete partner assets, skipping", + "home_page_delete_remote_err_local": "Local assets in delete remote selection, skipping", + "home_page_favorite_err_local": "Can not favorite local assets yet, skipping", + "home_page_favorite_err_partner": "Can not favorite partner assets yet, skipping", + "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", + "home_page_share_err_local": "Can not share local assets via link, skipping", + "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", + "image_saved_successfully": "Image saved", + "image_viewer_page_state_provider_download_error": "Download Error", + "image_viewer_page_state_provider_download_started": "Download Started", + "image_viewer_page_state_provider_download_success": "Download Success", + "image_viewer_page_state_provider_share_error": "Share Error", + "invalid_date": "Invalid date", + "invalid_date_format": "Invalid date format", + "library": "Library", + "library_page_albums": "Albums", + "library_page_archive": "Archive", + "library_page_device_albums": "Albums on Device", + "library_page_favorites": "Favorites", + "library_page_new_album": "New album", + "library_page_sharing": "Sharing", + "library_page_sort_asset_count": "Number of assets", + "library_page_sort_created": "Created date", + "library_page_sort_last_modified": "Last modified", + "library_page_sort_most_oldest_photo": "Oldest photo", + "library_page_sort_most_recent_photo": "Most recent photo", + "library_page_sort_title": "Album title", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", + "location_picker_choose_on_map": "Choose on map", + "location_picker_latitude": "Latitude", + "location_picker_latitude_error": "Enter a valid latitude", + "location_picker_latitude_hint": "Enter your latitude here", + "location_picker_longitude": "Longitude", + "location_picker_longitude_error": "Enter a valid longitude", + "location_picker_longitude_hint": "Enter your longitude here", + "login_disabled": "Login has been disabled", + "login_form_api_exception": "API exception. Please check the server URL and try again.", + "login_form_back_button_text": "Back", + "login_form_button_text": "Login", + "login_form_email_hint": "youremail@email.com", + "login_form_endpoint_hint": "http://your-server-ip:port", + "login_form_endpoint_url": "Server Endpoint URL", + "login_form_err_http": "Please specify http:// or https://", + "login_form_err_invalid_email": "Invalid Email", + "login_form_err_invalid_url": "Invalid URL", + "login_form_err_leading_whitespace": "Leading whitespace", + "login_form_err_trailing_whitespace": "Trailing whitespace", + "login_form_failed_get_oauth_server_config": "Error logging using OAuth, check server URL", + "login_form_failed_get_oauth_server_disable": "OAuth feature is not available on this server", + "login_form_failed_login": "Error logging you in, check server URL, email and password", + "login_form_handshake_exception": "There was an Handshake Exception with the server. Enable self-signed certificate support in the settings if you are using a self-signed certificate.", + "login_form_label_email": "Email", + "login_form_label_password": "Password", + "login_form_next_button": "Next", + "login_form_password_hint": "password", + "login_form_save_login": "Stay logged in", + "login_form_server_empty": "Enter a server URL.", + "login_form_server_error": "Could not connect to server.", + "login_password_changed_error": "There was an error updating your password", + "login_password_changed_success": "Password updated successfully", + "map_assets_in_bound": "{} photo", + "map_assets_in_bounds": "{} photos", + "map_cannot_get_user_location": "Cannot get user's location", + "map_location_dialog_cancel": "Cancel", + "map_location_dialog_yes": "Yes", + "map_location_picker_page_use_location": "Use this location", + "map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?", + "map_location_service_disabled_title": "Location Service disabled", + "map_no_assets_in_bounds": "No photos in this area", + "map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?", + "map_no_location_permission_title": "Location Permission denied", + "map_settings_dark_mode": "Dark mode", + "map_settings_date_range_option_all": "All", + "map_settings_date_range_option_day": "Past 24 hours", + "map_settings_date_range_option_days": "Past {} days", + "map_settings_date_range_option_year": "Past year", + "map_settings_date_range_option_years": "Past {} years", + "map_settings_dialog_cancel": "Cancel", + "map_settings_dialog_save": "Save", + "map_settings_dialog_title": "Map Settings", + "map_settings_include_show_archived": "Include Archived", + "map_settings_include_show_partners": "Include Partners", + "map_settings_only_relative_range": "Date range", + "map_settings_only_show_favorites": "Show Favorite Only", + "map_settings_theme_settings": "Map Theme", + "map_zoom_to_see_photos": "Zoom out to see photos", + "memories_all_caught_up": "All caught up", + "memories_check_back_tomorrow": "Check back tomorrow for more memories", + "memories_start_over": "Start Over", + "memories_swipe_to_close": "Swipe up to close", + "memories_year_ago": "A year ago", + "memories_years_ago": "{} years ago", + "monthly_title_text_date_format": "MMMM y", + "motion_photos_page_title": "Motion Photos", + "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", + "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "my_albums": "My albums", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", + "no_assets_to_show": "No assets to show", + "no_name": "No name", + "notification_permission_dialog_cancel": "Cancel", + "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", + "notification_permission_dialog_settings": "Settings", + "notification_permission_list_tile_content": "Grant permission to enable notifications.", + "notification_permission_list_tile_enable_button": "Enable Notifications", + "notification_permission_list_tile_title": "Notification Permission", + "not_selected": "Not selected", + "on_this_device": "On this device", + "partner_list_user_photos": "{user}'s photos", + "partner_list_view_all": "View all", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", + "partners": "Partners", + "paused": "Paused", + "people": "People", + "permission_onboarding_back": "Back", + "permission_onboarding_continue_anyway": "Continue anyway", + "permission_onboarding_get_started": "Get started", + "permission_onboarding_go_to_settings": "Go to settings", + "permission_onboarding_grant_permission": "Grant permission", + "permission_onboarding_log_out": "Log out", + "permission_onboarding_permission_denied": "Permission denied. To use Immich, grant photo and video permissions in Settings.", + "permission_onboarding_permission_granted": "Permission granted! You are all set.", + "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", + "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "places": "Places", + "preferences_settings_subtitle": "Manage the app's preferences", + "preferences_settings_title": "Preferences", + "profile_drawer_app_logs": "Logs", + "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", + "profile_drawer_client_out_of_date_minor": "Mobile App is out of date. Please update to the latest minor version.", + "profile_drawer_client_server_up_to_date": "Client and Server are up-to-date", + "profile_drawer_documentation": "Documentation", + "profile_drawer_github": "GitHub", + "profile_drawer_server_out_of_date_major": "Server is out of date. Please update to the latest major version.", + "profile_drawer_server_out_of_date_minor": "Server is out of date. Please update to the latest minor version.", + "profile_drawer_settings": "Settings", + "profile_drawer_sign_out": "Sign Out", + "profile_drawer_trash": "Trash", + "recently_added": "Recently added", + "recently_added_page_title": "Recently Added", + "save": "Save", + "save_to_gallery": "Save to gallery", + "scaffold_body_error_occurred": "Error occurred", + "search_albums": "Search albums", + "search_bar_hint": "Search your photos", + "search_filter_apply": "Apply filter", + "search_filter_camera": "Camera", + "search_filter_camera_make": "Make", + "search_filter_camera_model": "Model", + "search_filter_camera_title": "Select camera type", + "search_filter_date": "Date", + "search_filter_date_interval": "{start} to {end}", + "search_filter_date_title": "Select a date range", + "search_filter_display_option_archive": "Archive", + "search_filter_display_option_favorite": "Favorite", + "search_filter_display_option_not_in_album": "Not in album", + "search_filter_display_options": "Display Options", + "search_filter_display_options_title": "Display options", + "search_filter_location": "Location", + "search_filter_location_city": "City", + "search_filter_location_country": "Country", + "search_filter_location_state": "State", + "search_filter_location_title": "Select location", + "search_filter_media_type": "Media Type", + "search_filter_media_type_all": "All", + "search_filter_media_type_image": "Image", + "search_filter_media_type_title": "Select media type", + "search_filter_media_type_video": "Video", + "search_filter_people": "People", + "search_filter_people_title": "Select people", + "search_page_categories": "Categories", + "search_page_favorites": "Favorites", + "search_page_motion_photos": "Motion Photos", + "search_page_no_objects": "No Objects Info Available", + "search_page_no_places": "No Places Info Available", + "search_page_people": "People", + "search_page_person_add_name_dialog_cancel": "Cancel", + "search_page_person_add_name_dialog_hint": "Name", + "search_page_person_add_name_dialog_save": "Save", + "search_page_person_add_name_dialog_title": "Add a name", + "search_page_person_add_name_subtitle": "Find them fast by name with search", + "search_page_person_add_name_title": "Add a name", + "search_page_person_edit_name": "Edit name", + "search_page_places": "Places", + "search_page_recently_added": "Recently added", + "search_page_screenshots": "Screenshots", + "search_page_search_photos_videos": "Search for your photos and videos", + "search_page_selfies": "Selfies", + "search_page_things": "Things", + "search_page_videos": "Videos", + "search_page_view_all_button": "View all", + "search_page_your_activity": "Your activity", + "search_page_your_map": "Your Map", + "search_result_page_new_search_hint": "New Search", + "search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ", + "search_suggestion_list_smart_search_hint_2": "m:your-search-term", + "select_additional_user_for_sharing_page_suggestions": "Suggestions", + "select_user_for_sharing_page_err_album": "Failed to create album", + "select_user_for_sharing_page_share_suggestions": "Suggestions", + "server_endpoint": "Server Endpoint", + "server_info_box_app_version": "App Version", + "server_info_box_latest_release": "Latest Version", + "server_info_box_server_url": "Server URL", + "server_info_box_server_version": "Server Version", + "setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).", + "setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).", + "setting_image_viewer_original_title": "Load original image", + "setting_image_viewer_preview_subtitle": "Enable to load a medium-resolution image. Disable to either directly load the original or only use the thumbnail.", + "setting_image_viewer_preview_title": "Load preview image", + "setting_image_viewer_title": "Images", + "setting_languages_apply": "Apply", + "setting_languages_subtitle": "Change the app's language", + "setting_languages_title": "Languages", + "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}", + "setting_notifications_notify_hours": "{} hours", + "setting_notifications_notify_immediately": "immediately", + "setting_notifications_notify_minutes": "{} minutes", + "setting_notifications_notify_never": "never", + "setting_notifications_notify_seconds": "{} seconds", + "setting_notifications_single_progress_subtitle": "Detailed upload progress information per asset", + "setting_notifications_single_progress_title": "Show background backup detail progress", + "setting_notifications_subtitle": "Adjust your notification preferences", + "setting_notifications_title": "Notifications", + "setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)", + "setting_notifications_total_progress_title": "Show background backup total progress", + "setting_pages_app_bar_settings": "Settings", + "settings_require_restart": "Please restart Immich to apply this setting", + "setting_video_viewer_looping_subtitle": "Enable to automatically loop a video in the detail viewer.", + "setting_video_viewer_looping_title": "Looping", + "setting_video_viewer_original_video_subtitle": "When streaming a video from the server, play the original even when a transcode is available. May lead to buffering. Videos available locally are played in original quality regardless of this setting.", + "setting_video_viewer_original_video_title": "Force original video", + "setting_video_viewer_title": "Videos", + "share_add": "Add", + "share_add_photos": "Add photos", + "share_add_title": "Add a title", + "share_assets_selected": "{} selected", + "share_create_album": "Create album", + "shared_album_activities_input_disable": "Comment is disabled", + "shared_album_activities_input_hint": "Say something", + "shared_album_activity_remove_content": "Do you want to delete this activity?", + "shared_album_activity_remove_title": "Delete Activity", + "shared_album_activity_setting_subtitle": "Let others respond", + "shared_album_activity_setting_title": "Comments & likes", + "shared_album_section_people_action_error": "Error leaving/removing from album", + "shared_album_section_people_action_leave": "Remove user from album", + "shared_album_section_people_action_remove_user": "Remove user from album", + "shared_album_section_people_owner_label": "Owner", + "shared_album_section_people_title": "PEOPLE", + "share_dialog_preparing": "Preparing...", + "shared_intent_upload_button_progress_text": "{} / {} Uploaded", + "shared_link_app_bar_title": "Shared Links", + "shared_link_clipboard_copied_massage": "Copied to clipboard", + "shared_link_clipboard_text": "Link: {}\nPassword: {}", + "shared_link_create_app_bar_title": "Create link to share", + "shared_link_create_error": "Error while creating shared link", + "shared_link_create_info": "Let anyone with the link see the selected photo(s)", + "shared_link_create_submit_button": "Create link", + "shared_link_edit_allow_download": "Allow public user to download", + "shared_link_edit_allow_upload": "Allow public user to upload", + "shared_link_edit_app_bar_title": "Edit link", + "shared_link_edit_change_expiry": "Change expiration time", + "shared_link_edit_description": "Description", + "shared_link_edit_description_hint": "Enter the share description", + "shared_link_edit_expire_after": "Expire after", + "shared_link_edit_expire_after_option_day": "1 day", + "shared_link_edit_expire_after_option_days": "{} days", + "shared_link_edit_expire_after_option_hour": "1 hour", + "shared_link_edit_expire_after_option_hours": "{} hours", + "shared_link_edit_expire_after_option_minute": "1 minute", + "shared_link_edit_expire_after_option_minutes": "{} minutes", + "shared_link_edit_expire_after_option_months": "{} months", + "shared_link_edit_expire_after_option_never": "Never", + "shared_link_edit_expire_after_option_year": "{} year", + "shared_link_edit_password": "Password", + "shared_link_edit_password_hint": "Enter the share password", + "shared_link_edit_show_meta": "Show metadata", + "shared_link_edit_submit_button": "Update link", + "shared_link_empty": "You don't have any shared links", + "shared_link_error_server_url_fetch": "Cannot fetch the server url", + "shared_link_expired": "Expired", + "shared_link_expires_day": "Expires in {} day", + "shared_link_expires_days": "Expires in {} days", + "shared_link_expires_hour": "Expires in {} hour", + "shared_link_expires_hours": "Expires in {} hours", + "shared_link_expires_minute": "Expires in {} minute", + "shared_link_expires_minutes": "Expires in {} minutes", + "shared_link_expires_never": "Expires ∞", + "shared_link_expires_second": "Expires in {} second", + "shared_link_expires_seconds": "Expires in {} seconds", + "shared_link_individual_shared": "Individual shared", + "shared_link_info_chip_download": "Download", + "shared_link_info_chip_metadata": "EXIF", + "shared_link_info_chip_upload": "Upload", + "shared_link_manage_links": "Manage Shared links", + "shared_link_public_album": "Public album", + "shared_links": "Shared links", + "share_done": "Done", + "shared_with_me": "Shared with me", + "share_invite": "Invite to album", + "sharing_page_album": "Shared albums", + "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", + "sharing_page_empty_list": "EMPTY LIST", + "sharing_silver_appbar_create_shared_album": "New shared album", + "sharing_silver_appbar_shared_links": "Shared links", + "sharing_silver_appbar_share_partner": "Share with partner", + "start_date": "Start date", + "sync": "Sync", + "sync_albums": "Sync albums", + "sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums", + "sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich", + "tab_controller_nav_library": "Library", + "tab_controller_nav_photos": "Photos", + "tab_controller_nav_search": "Search", + "tab_controller_nav_sharing": "Sharing", + "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", + "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", + "theme_setting_dark_mode_switch": "Dark mode", + "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", + "theme_setting_image_viewer_quality_title": "Image viewer quality", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", + "theme_setting_system_theme_switch": "Automatic (Follow system setting)", + "theme_setting_theme_subtitle": "Choose the app's theme setting", + "theme_setting_theme_title": "Theme", + "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", + "theme_setting_three_stage_loading_title": "Enable three-stage loading", + "translated_text_options": "Options", + "trash": "Trash", + "trash_emptied": "Emptied trash", + "trash_page_delete": "Delete", + "trash_page_delete_all": "Delete All", + "trash_page_empty_trash_btn": "Empty trash", + "trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich", + "trash_page_empty_trash_dialog_ok": "Ok", + "trash_page_info": "Trashed items will be permanently deleted after {} days", + "trash_page_no_assets": "No trashed assets", + "trash_page_restore": "Restore", + "trash_page_restore_all": "Restore All", + "trash_page_select_assets_btn": "Select assets", + "trash_page_select_btn": "Select", + "trash_page_title": "Trash ({})", + "upload": "Upload", + "upload_dialog_cancel": "Cancel", + "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", + "upload_dialog_ok": "Upload", + "upload_dialog_title": "Upload Asset", + "uploading": "Uploading", + "upload_to_immich": "Upload to Immich ({})", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", + "version_announcement_overlay_ack": "Acknowledge", + "version_announcement_overlay_release_notes": "release notes", + "version_announcement_overlay_text_1": "Hi friend, there is a new release of", + "version_announcement_overlay_text_2": "please take your time to visit the ", + "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", + "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", + "videos": "Videos", + "viewer_remove_from_stack": "Remove from Stack", + "viewer_stack_use_as_main_asset": "Use as Main Asset", + "viewer_unstack": "Un-Stack", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" +} diff --git a/mobile/assets/i18n/he-IL.json b/mobile/assets/i18n/he-IL.json index 3f05db43fe..01ef3c0d00 100644 --- a/mobile/assets/i18n/he-IL.json +++ b/mobile/assets/i18n/he-IL.json @@ -157,7 +157,7 @@ "cache_settings_tile_title": "אחסון מקומי", "cache_settings_title": "הגדרות שמירת מטמון", "cancel": "ביטול", - "canceled": "Canceled", + "canceled": "בוטל", "change_display_order": "שנה סדר תצוגה", "change_password_form_confirm_password": "אשר סיסמה", "change_password_form_description": "הי {name},\n\nזאת או הפעם הראשונה שאת/ה מתחבר/ת למערכת או שנעשתה בקשה לשינוי הסיסמה שלך. נא להזין את הסיסמה החדשה למטה.", @@ -181,7 +181,7 @@ "common_create_new_album": "צור אלבום חדש", "common_server_error": "נא לבדוק את חיבור הרשת שלך, תוודא/י שהשרת נגיש ושגרסאות אפליקציה/שרת תואמות", "common_shared": "משותף", - "completed": "Completed", + "completed": "הושלמו", "contextual_search": "Sunrise on the beach (מומלץ לחפש באנגלית לתוצאות טובות יותר)", "control_bottom_app_bar_add_to_album": "הוסף לאלבום", "control_bottom_app_bar_album_info": "{} פריטים", @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "מחק קישור משותף", "description_input_hint_text": "הוסף תיאור...", "description_input_submit_error": "שגיאה בעדכון תיאור, בדוק את היומן לפרטים נוספים", + "description_search": "Hiking day in Sapa", "download_canceled": "הורדה בוטלה", "download_complete": "הורדה הושלמה", "download_enqueue": "הורדה נוספה לתור", @@ -247,11 +248,12 @@ "download_sucess_android": "המדיה הורדה אל DCIM/Immich", "download_waiting_to_retry": "מחכה כדי לנסות שוב", "edit_date_time_dialog_date_time": "תאריך וזמן", + "edit_date_time_dialog_search_timezone": "חפש אזור זמן...", "edit_date_time_dialog_timezone": "אזור זמן", "edit_image_title": "ערוך", "edit_location_dialog_title": "מיקום", - "end_date": "End date", - "enqueued": "Enqueued", + "end_date": "תאריך סיום", + "enqueued": "הוצבו בתור", "enter_wifi_name": "הזן שם אינטרנט אלחוטי", "error_change_sort_album": "שינוי סדר מיון אלבום נכשל", "error_saving_image": "שגיאה: {}", @@ -267,7 +269,7 @@ "experimental_settings_title": "נסיוני", "external_network": "רשת חיצונית", "external_network_sheet_info": "כאשר לא על רשת האינטרנט האלחוטי המועדפת, היישום יתחבר לשרת דרך הכתובת הראשונה שניתן להשיג מהכתובות שלהלן, החל מלמעלה למטה", - "failed": "Failed", + "failed": "נכשלו", "favorites": "מועדפים", "favorites_page_no_favorites": "לא נמצאו נכסים מועדפים", "favorites_page_title": "מועדפים", @@ -336,9 +338,9 @@ "login_form_back_button_text": "חזרה", "login_form_button_text": "התחברות", "login_form_email_hint": "yourmail@email.com", - "login_form_endpoint_hint": "http://your-server-ip:port/API", + "login_form_endpoint_hint": "http://your-server-ip:port", "login_form_endpoint_url": "כתובת נקודת קצה השרת", - "login_form_err_http": "נא לציין //:htttp או //:https", + "login_form_err_http": "נא לציין //:http או //:https", "login_form_err_invalid_email": "דוא\"ל שגוי", "login_form_err_invalid_url": "כתובת לא חוקית", "login_form_err_leading_whitespace": "רווח לבן מוביל", @@ -403,7 +405,7 @@ "notification_permission_list_tile_content": "הענק הרשאה כדי לאפשר התראות", "notification_permission_list_tile_enable_button": "אפשר התראות", "notification_permission_list_tile_title": "הרשאת התראה", - "not_selected": "Not selected", + "not_selected": "לא נבחרו", "on_this_device": "במכשיר הזה", "partner_list_user_photos": "תמונות של {user}", "partner_list_view_all": "הצג הכל", @@ -417,7 +419,7 @@ "partner_page_stop_sharing_title": "להפסיק לשתף את התמונות שלך?", "partner_page_title": "שותף", "partners": "שותפים", - "paused": "Paused", + "paused": "הופסק", "people": "אנשים", "permission_onboarding_back": "חזרה", "permission_onboarding_continue_anyway": "המשך בכל זאת", @@ -455,14 +457,17 @@ "search_filter_camera_make": "תוצרת", "search_filter_camera_model": "דגם", "search_filter_camera_title": "בחר סוג מצלמה", + "search_filter_contextual": "Search by context", "search_filter_date": "תאריך", "search_filter_date_interval": "{start} עד {end}", "search_filter_date_title": "בחר טווח תאריכים", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "ארכיון", "search_filter_display_option_favorite": "מועדף", "search_filter_display_option_not_in_album": "לא באלבום", "search_filter_display_options": "אפשרויות תצוגה", "search_filter_display_options_title": "אפשרויות תצוגה", + "search_filter_filename": "Search by file name", "search_filter_location": "מיקום", "search_filter_location_city": "עיר", "search_filter_location_country": "ארץ", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "בחר סוג מדיה", "search_filter_media_type_video": "סרטון", "search_filter_people": "אנשים", + "search_filter_people_hint": "סינון אנשים", "search_filter_people_title": "בחר אנשים", + "search_no_more_result": "אין עוד תוצאות", + "search_no_result": "לא נמצאו תוצאות, יש לנסות ביטוי או צירוף חיפוש שונה", "search_page_categories": "קטגוריות", "search_page_favorites": "מועדפים", "search_page_motion_photos": "תמונות עם תנועה", @@ -534,8 +542,8 @@ "settings_require_restart": "אנא הפעל מחדש את היישום כדי להחיל הגדרה זו", "setting_video_viewer_looping_subtitle": "אפשר הפעלה חוזרת אוטומטית של סרטון במציג הפרטים", "setting_video_viewer_looping_title": "הפעלה חוזרת", - "setting_video_viewer_original_video_subtitle": "When streaming a video from the server, play the original even when a transcode is available. May lead to buffering. Videos available locally are played in original quality regardless of this setting.", - "setting_video_viewer_original_video_title": "Force original video", + "setting_video_viewer_original_video_subtitle": "כאשר מזרימים סרטון מהשרת, נגן את המקורי אפילו כשהמרת קידוד זמינה. עלול להוביל לתקיעות. סרטונים זמינים מקומית מנוגנים באיכות מקורית ללא קשר להגדרה זו.", + "setting_video_viewer_original_video_title": "כפה סרטון מקורי", "setting_video_viewer_title": "סרטונים", "share_add": "הוסף", "share_add_photos": "הוסף תמונות", @@ -554,7 +562,7 @@ "shared_album_section_people_owner_label": "בעלים", "shared_album_section_people_title": "אנשים", "share_dialog_preparing": "מכין...", - "shared_intent_upload_button_progress_text": "{} / {} Uploaded", + "shared_intent_upload_button_progress_text": "הועלו {} / {}", "shared_link_app_bar_title": "קישורים משותפים", "shared_link_clipboard_copied_massage": "הועתק ללוח", "shared_link_clipboard_text": "קישור: {}\nסיסמה: {}", @@ -610,7 +618,7 @@ "sharing_silver_appbar_create_shared_album": "אלבום משותף חדש", "sharing_silver_appbar_shared_links": "קישורים משותפים", "sharing_silver_appbar_share_partner": "שיתוף עם שותף", - "start_date": "Start date", + "start_date": "תאריך התחלה", "sync": "סנכרן", "sync_albums": "סנכרן אלבומים", "sync_albums_manual_subtitle": "סנכרן את כל הסרטונים והתמונות שהועלו לאלבומי הגיבוי שנבחרו", @@ -649,13 +657,13 @@ "trash_page_select_assets_btn": "בחר נכסים", "trash_page_select_btn": "בחר", "trash_page_title": "אשפה ({})", - "upload": "Upload", + "upload": "העלאה", "upload_dialog_cancel": "ביטול", "upload_dialog_info": "האם ברצונך לגבות את הנכס(ים) שנבחרו לשרת?", "upload_dialog_ok": "העלאה", "upload_dialog_title": "העלאת נכס", - "uploading": "Uploading", - "upload_to_immich": "Upload to Immich ({})", + "uploading": "מעלה", + "upload_to_immich": "העלה לשרת ({})", "use_current_connection": "השתמש בחיבור נוכחי", "validate_endpoint_error": "נא להזין כתובת תקנית", "version_announcement_overlay_ack": "אשר", diff --git a/mobile/assets/i18n/hi-IN.json b/mobile/assets/i18n/hi-IN.json index b22f1171a8..5b64f8c674 100644 --- a/mobile/assets/i18n/hi-IN.json +++ b/mobile/assets/i18n/hi-IN.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "साझा किए गए लिंक को हटाएं", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", + "description_search": "Hiking day in Sapa", "download_canceled": "डाउनलोड रद्द कर दिया गया", "download_complete": "डाउनलोड पूरा", "download_enqueue": "डाउनलोड कतार में है", @@ -247,6 +248,7 @@ "download_sucess_android": "मीडिया DCIM/Immich में डाउनलोड हो गया है", "download_waiting_to_retry": "पुनः प्रयास करने का इंतजार कर रहा है", "edit_date_time_dialog_date_time": "Date and Time", + "edit_date_time_dialog_search_timezone": "Search timezone...", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "संपादित करें", "edit_location_dialog_title": "Location", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Back", "login_form_button_text": "Login", "login_form_email_hint": "youremail@email.com", - "login_form_endpoint_hint": "http://your-server-ip:port/api", + "login_form_endpoint_hint": "http://your-server-ip:port", "login_form_endpoint_url": "Server Endpoint URL", "login_form_err_http": "Please specify http:// or https://", "login_form_err_invalid_email": "Invalid Email", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Make", "search_filter_camera_model": "Model", "search_filter_camera_title": "कैमरा प्रकार चुनें", + "search_filter_contextual": "Search by context", "search_filter_date": "तारीख़", "search_filter_date_interval": "{start} से {end} तक", "search_filter_date_title": "तारीख़ की सीमा चुनें", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "Archive", "search_filter_display_option_favorite": "Favorite", "search_filter_display_option_not_in_album": "Not in album", "search_filter_display_options": "प्रदर्शन विकल्प", "search_filter_display_options_title": "प्रदर्शन विकल्प", + "search_filter_filename": "Search by file name", "search_filter_location": "स्थान", "search_filter_location_city": "City", "search_filter_location_country": "Country", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "मीडिया प्रकार चुनें", "search_filter_media_type_video": "Video", "search_filter_people": "लोग", + "search_filter_people_hint": "Filter people", "search_filter_people_title": "लोगों का चयन करें", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "Categories", "search_page_favorites": "Favorites", "search_page_motion_photos": "Motion Photos", diff --git a/mobile/assets/i18n/hu-HU.json b/mobile/assets/i18n/hu-HU.json index 69c21d931a..b450355d95 100644 --- a/mobile/assets/i18n/hu-HU.json +++ b/mobile/assets/i18n/hu-HU.json @@ -7,7 +7,7 @@ "action_common_select": "Kiválaszt", "action_common_update": "Frissít", "add_a_name": "Név hozzáadása", - "add_endpoint": "Add endpoint", + "add_endpoint": "Végpont megadása", "add_to_album_bottom_sheet_added": "Hozzáadva a(z) \"{album}\" albumhoz", "add_to_album_bottom_sheet_already_exists": "Már benne van a(z) \"{album}\" albumban", "advanced_settings_log_level_title": "Naplózás szintje: {}", @@ -66,12 +66,12 @@ "assets_restored_successfully": "{} elem sikeresen helyreállítva", "assets_trashed": "{} elem lomtárba helyezve", "assets_trashed_from_server": "{} elem lomtárba helyezve az Immich szerveren", - "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", + "asset_viewer_settings_subtitle": "A képnézegető beállításainak kezelése", "asset_viewer_settings_title": "Elem Megjelenítő", - "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", - "automatic_endpoint_switching_title": "Automatic URL switching", - "background_location_permission": "Background location permission", - "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", + "automatic_endpoint_switching_subtitle": "A megadott WiFi-n keresztül helyi hálózaton keresztül kapcsolódolik, egyébként az alternatív címeket használja", + "automatic_endpoint_switching_title": "Automatikus URL cím váltás", + "background_location_permission": "Háttérben történő helymeghatározási engedély", + "background_location_permission_content": "Hálózatok automatikus váltásához az Immich-nek *mindenképpen* hozzá kell férnie a pontos helyzethez, hogy az alkalmazás le tudja kérni a Wi-Fi hálózat nevét", "backup_album_selection_page_albums_device": "Ezen az eszközön lévő albumok ({})", "backup_album_selection_page_albums_tap": "Koppints a hozzáadáshoz, duplán koppints az eltávolításhoz", "backup_album_selection_page_assets_scatter": "Egy elem több albumban is lehet. Ezért a mentéshez albumokat lehet hozzáadni vagy azokat a mentésből kihagyni.", @@ -137,7 +137,7 @@ "backup_manual_success": "Sikeres", "backup_manual_title": "Feltöltés állapota", "backup_options_page_title": "Biztonági mentés beállításai", - "backup_setting_subtitle": "Manage background and foreground upload settings", + "backup_setting_subtitle": "A háttérben és előtérben mentés beállításainak kezelése", "cache_settings_album_thumbnails": "Képtár oldalankénti bélyegképei ({} elem)", "cache_settings_clear_cache_button": "Gyorsítótár kiürítése", "cache_settings_clear_cache_button_title": "Kiüríti az alkalmazás gyorsítótárát. Ez jelentősen kihat az alkalmazás teljesítményére, amíg a gyorsítótár újra nem épül.", @@ -156,17 +156,17 @@ "cache_settings_tile_subtitle": "Helyi tárhely viselkedésének beállítása", "cache_settings_tile_title": "Helyi Tárhely", "cache_settings_title": "Gyorsítótár Beállítások", - "cancel": "Cancel", - "canceled": "Canceled", - "change_display_order": "Change display order", + "cancel": "Mégsem", + "canceled": "Megszakítva", + "change_display_order": "Megjelenítési sorrend megváltoztatása", "change_password_form_confirm_password": "Jelszó Megerősítése", "change_password_form_description": "Szia {name}!\n\nMost jelentkezel be először a rendszerbe vagy más okból szükséges a jelszavad meváltoztatása. Kérjük, add meg új jelszavad.", "change_password_form_new_password": "Új Jelszó", "change_password_form_password_mismatch": "A beírt jelszavak nem egyeznek", "change_password_form_reenter_new_password": "Jelszó (Még Egyszer)", - "check_corrupt_asset_backup": "Check for corrupt asset backups", - "check_corrupt_asset_backup_button": "Perform check", - "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", + "check_corrupt_asset_backup": "Sérült elemek keresése a mentésben", + "check_corrupt_asset_backup_button": "Ellenőrzés", + "check_corrupt_asset_backup_description": "Ezt az ellenőtzést csak Wi-Fi hálózaton futtasd és csak akkot, ha már az összes elem feltöltésre került. A folyamat néhány percig is eltarthat.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Jelszó Megadása", "client_cert_import": "Importálás", @@ -181,7 +181,7 @@ "common_create_new_album": "Új album létrehozása", "common_server_error": "Kérjük, ellenőrizd a hálózati kapcsolatot, gondoskodj róla, hogy a szerver elérhető legyen, valamint az alkalmazás és a szerver kompatibilis verziójú legyen.", "common_shared": "Megosztott", - "completed": "Completed", + "completed": "Kész", "contextual_search": "Napfelkelte a tengerparton", "control_bottom_app_bar_add_to_album": "Albumhoz ad", "control_bottom_app_bar_album_info": "{} elem", @@ -213,7 +213,7 @@ "crop": "Kivágás", "curated_location_page_title": "Helyek", "curated_object_page_title": "Dolgok", - "current_server_address": "Current server address", + "current_server_address": "Jelenlegi szerver cím", "daily_title_text_date": "MMM dd (E)", "daily_title_text_date_year": "yyyy MMM dd (E)", "date_format": "y LLL d (E) • HH:mm", @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Megosztott Link Törlése", "description_input_hint_text": "Leírás hozzáadása...", "description_input_submit_error": "Nem sikerült frissíteni a leírást. További információért kérjük, nézd meg az eseménynaplót", + "description_search": "Visegrádi kirándulás", "download_canceled": "Letöltés megszakítva", "download_complete": "Letöltés kész", "download_enqueue": "Letöltés sorba állítva", @@ -247,13 +248,14 @@ "download_sucess_android": "Média letöltve a DCIM/Immich mappába\n", "download_waiting_to_retry": "Várakozás", "edit_date_time_dialog_date_time": "Dátum és Idő", + "edit_date_time_dialog_search_timezone": "Időzóna keresése...", "edit_date_time_dialog_timezone": "Időzóna", "edit_image_title": "Szerkesztés", "edit_location_dialog_title": "Hely", - "end_date": "End date", - "enqueued": "Enqueued", - "enter_wifi_name": "Enter WiFi name", - "error_change_sort_album": "Failed to change album sort order", + "end_date": "Befejező dátum", + "enqueued": "Sorba állítva", + "enter_wifi_name": "Add meg a WiFi hálózat nevét", + "error_change_sort_album": "Album sorbarendezésének megváltoztatása sikertelen", "error_saving_image": "Hiba: {}", "exif_bottom_sheet_description": "Leírás Hozzáadása...", "exif_bottom_sheet_details": "RÉSZLETEK", @@ -265,16 +267,16 @@ "experimental_settings_new_asset_list_title": "Kisérleti képrács engedélyezése", "experimental_settings_subtitle": "Csak saját felelősségre használd!", "experimental_settings_title": "Kísérleti", - "external_network": "External network", - "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", - "failed": "Failed", + "external_network": "Külső hálózat", + "external_network_sheet_info": "Ha nem vagy a megadott WiFi hálózathoz csatlakozva, akkor az alkalmazás az alábbi URL címeken fogja elérni a szervert, fentről lefelé haladva", + "failed": "Sikertelen", "favorites": "Kedvencek", "favorites_page_no_favorites": "Nem található kedvencnek jelölt elem", "favorites_page_title": "Kedvencek", "filename_search": "Fájlnév vagy kiterjesztés", "filter": "Szűrő", - "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", - "grant_permission": "Grant permission", + "get_wifiname_error": "Nem sikerült lekérni a Wi-Fi nevét. Győződj meg róla, hogy megadtad a szükséges engedélyeket és csatlakoztál egy Wi-Fi hálózathoz.", + "grant_permission": "Engedély megadása", "haptic_feedback_switch": "Rezgéses visszajelzés engedélyezése", "haptic_feedback_title": "Rezgéses Visszajelzés", "header_settings_add_header_tip": "Fejléc Hozzáadása", @@ -320,10 +322,10 @@ "library_page_sort_most_oldest_photo": "Legrégebbi fotó", "library_page_sort_most_recent_photo": "Legújabb fotó", "library_page_sort_title": "Album címe", - "local_network": "Local network", - "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", - "location_permission": "Location permission", - "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", + "local_network": "Helyi hálózat", + "local_network_sheet_info": "Az alkalmazés ezen az URL címen fogja elérni a szervert, ha a megadott WiFi hálózathoz van csatlankozva", + "location_permission": "Helymeghatározási engedély", + "location_permission_content": "Hálózatok automatikus váltásához az Immich-nek *mindenképpen* hozzá kell férnie a pontos helyzethez, hogy az alkalmazás le tudja kérni a Wi-Fi hálózat nevét", "location_picker_choose_on_map": "Válassz a térképen", "location_picker_latitude": "Szélességi kör", "location_picker_latitude_error": "Érvényes szélességi kört írj be", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Vissza", "login_form_button_text": "Bejelentkezés", "login_form_email_hint": "email@cimed.hu", - "login_form_endpoint_hint": "http(s)://szerver-címe:port/api", + "login_form_endpoint_hint": "http://szerver-címe:port", "login_form_endpoint_url": "Szerver címe", "login_form_err_http": "Kérjük, hogy egy http:// vagy https:// címet adj meg", "login_form_err_invalid_email": "Érvénytelen email cím", @@ -393,8 +395,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Csak-olvasható elem(ek) dátuma nem módosítható, ezért kihagyjuk", "multiselect_grid_edit_gps_err_read_only": "Csak-olvasható elem(ek) helye nem módosítható, ezért kihagyjuk", "my_albums": "Saját albumaim", - "networking_settings": "Networking", - "networking_subtitle": "Manage the server endpoint settings", + "networking_settings": "Hálózat", + "networking_subtitle": "Szerver végpont beállítások kezelése", "no_assets_to_show": "Nincs megjeleníthető elem", "no_name": "Névtelen", "notification_permission_dialog_cancel": "Mégsem", @@ -403,7 +405,7 @@ "notification_permission_list_tile_content": "Értesítések engedélyezése.", "notification_permission_list_tile_enable_button": "Értesítések Bekapcsolása", "notification_permission_list_tile_title": "Engedély az Értesítésekhez", - "not_selected": "Not selected", + "not_selected": "Nincs kiválasztva", "on_this_device": "Ezen az eszközön", "partner_list_user_photos": "{user} fényképei", "partner_list_view_all": "Összes mutatása", @@ -417,7 +419,7 @@ "partner_page_stop_sharing_title": "Fotók megosztásának megszűntetése?", "partner_page_title": "Partner", "partners": "Partnerek", - "paused": "Paused", + "paused": "Szüneteltetve", "people": "Emberek", "permission_onboarding_back": "Vissza", "permission_onboarding_continue_anyway": "Folytatás mindenképp", @@ -430,7 +432,7 @@ "permission_onboarding_permission_limited": "Korlátozott hozzáférés. Ha szeretnéd, hogy az Immich a teljes galéria gyűjteményedet mentse és kezelje, akkor a Beállításokban engedélyezd a fotó és videó jogosultságokat.", "permission_onboarding_request": "Engedélyezni kell, hogy az Immich hozzáférjen a képeidhez és videóidhoz", "places": "Helyek", - "preferences_settings_subtitle": "Manage the app's preferences", + "preferences_settings_subtitle": "Alkalmazásbeállítások kezelése", "preferences_settings_title": "Beállítások", "profile_drawer_app_logs": "Naplók", "profile_drawer_client_out_of_date_major": "A mobilalkalmazás elavult. Kérjük, frissítsd a legfrisebb főverzióra.", @@ -445,7 +447,7 @@ "profile_drawer_trash": "Lomtár", "recently_added": "Nemrég hozzáadott", "recently_added_page_title": "Nemrég Hozzáadott", - "save": "Save", + "save": "Mentés", "save_to_gallery": "Mentés a galériába", "scaffold_body_error_occurred": "Hiba történt", "search_albums": "Albumok keresése", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Gyártó", "search_filter_camera_model": "Modell", "search_filter_camera_title": "Válaszd ki a kamera típusát", + "search_filter_contextual": "Keresés a kép tartalma alapján", "search_filter_date": "Dátum", "search_filter_date_interval": "{start} - {end}", "search_filter_date_title": "Válassz dátum intervallumot", + "search_filter_description": "Keresés leírás alapján", "search_filter_display_option_archive": "Archivált", "search_filter_display_option_favorite": "Kedvenc", "search_filter_display_option_not_in_album": "Nincs albumban", "search_filter_display_options": "Megjelenítési Beállítások", "search_filter_display_options_title": "Megjelenítési beállítások", + "search_filter_filename": "Keresés fájlnév alapján", "search_filter_location": "Hely", "search_filter_location_city": "Település", "search_filter_location_country": "Ország", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Válassz média típust", "search_filter_media_type_video": "Videó", "search_filter_people": "Emberek", + "search_filter_people_hint": "Emberek szűrése", "search_filter_people_title": "Válassz embereket", + "search_no_more_result": "Nincs több találat", + "search_no_result": "Nincs találat, próbálj más kulcsszavakkal keresni", "search_page_categories": "Kategóriák", "search_page_favorites": "Kedvencek", "search_page_motion_photos": "Mozgóképek", @@ -491,7 +499,7 @@ "search_page_places": "Helyek", "search_page_recently_added": "Nemrég hozzáadott", "search_page_screenshots": "Képernyőképek", - "search_page_search_photos_videos": "Search for your photos and videos", + "search_page_search_photos_videos": "Keresés a fotóid és videóid közt", "search_page_selfies": "Szelfik", "search_page_things": "Dolgok", "search_page_videos": "Videók", @@ -504,7 +512,7 @@ "select_additional_user_for_sharing_page_suggestions": "Javaslatok", "select_user_for_sharing_page_err_album": "Az album létrehozása sikertelen", "select_user_for_sharing_page_share_suggestions": "Javaslatok", - "server_endpoint": "Server Endpoint", + "server_endpoint": "Szerver Végpont", "server_info_box_app_version": "Alkalmazás Verzió", "server_info_box_latest_release": "Legfrissebb Verzió", "server_info_box_server_url": "Szerver Címe", @@ -516,7 +524,7 @@ "setting_image_viewer_preview_title": "Előnézet betöltése", "setting_image_viewer_title": "Képek", "setting_languages_apply": "Alkalmaz", - "setting_languages_subtitle": "Change the app's language", + "setting_languages_subtitle": "Az alkalmazás nyelvének megváltoztatása", "setting_languages_title": "Nyelvek", "setting_notifications_notify_failures_grace_period": "Értesítés a háttérben történő mentés hibáiról: {}", "setting_notifications_notify_hours": "{} óra", @@ -534,8 +542,8 @@ "settings_require_restart": "Ennek a beállításnak az érvénybe lépéséhez indítsd újra az Immich-et", "setting_video_viewer_looping_subtitle": "Engedélyezi a videók folyamatosan ismételt lejátszását az elem megjelenítőben", "setting_video_viewer_looping_title": "Ismétlés", - "setting_video_viewer_original_video_subtitle": "When streaming a video from the server, play the original even when a transcode is available. May lead to buffering. Videos available locally are played in original quality regardless of this setting.", - "setting_video_viewer_original_video_title": "Force original video", + "setting_video_viewer_original_video_subtitle": "A szerverről történő videólejátszás során az eredeti videó lejátszása még akkor is, ha van optimalizált, átkódolt verzió. Akadozó lejátszást eredményezhet. A helyi eszközön eleve elérhető videókat mindenképpen eredeti minőségben játszuk le.", + "setting_video_viewer_original_video_title": "Eredeti videó lejátszása", "setting_video_viewer_title": "Videók", "share_add": "Hozzáadás", "share_add_photos": "Fotók hozzáadása", @@ -554,7 +562,7 @@ "shared_album_section_people_owner_label": "Tulajdonos", "shared_album_section_people_title": "EMBEREK", "share_dialog_preparing": "Előkészítés...", - "shared_intent_upload_button_progress_text": "{} / {} Uploaded", + "shared_intent_upload_button_progress_text": "{} / {} Feltöltve", "shared_link_app_bar_title": "Megosztott Linkek", "shared_link_clipboard_copied_massage": "Vágólapra másolva", "shared_link_clipboard_text": "Link: {}\nJelszó: {}", @@ -610,7 +618,7 @@ "sharing_silver_appbar_create_shared_album": "Új megosztott album", "sharing_silver_appbar_shared_links": "Megosztott linkek", "sharing_silver_appbar_share_partner": "Megosztás partnerrel", - "start_date": "Start date", + "start_date": "Kezdő dátum", "sync": "Szinkronizálás", "sync_albums": "Albumok szinkronizálása", "sync_albums_manual_subtitle": "Összes fotó és videó létrehozása és szinkronizálása a kiválasztott Immich albumokba", @@ -649,15 +657,15 @@ "trash_page_select_assets_btn": "Elemek kiválasztása", "trash_page_select_btn": "Kiválaszt", "trash_page_title": "Lomtár ({})", - "upload": "Upload", + "upload": "Feltöltés", "upload_dialog_cancel": "Mégsem", "upload_dialog_info": "Szeretnél mentést készíteni a kiválasztott elem(ek)ről a szerverre?", "upload_dialog_ok": "Feltöltés", "upload_dialog_title": "Elem Feltöltése", - "uploading": "Uploading", - "upload_to_immich": "Upload to Immich ({})", - "use_current_connection": "use current connection", - "validate_endpoint_error": "Please enter a valid URL", + "uploading": "Feltöltés folyamatban", + "upload_to_immich": "Feltöltés Immich-be ({})", + "use_current_connection": "Jelenlegi kapcsolat használata", + "validate_endpoint_error": "Kérlek, érvényes URL címet adj meg", "version_announcement_overlay_ack": "Megértettem", "version_announcement_overlay_release_notes": "kiadási megjegyzések áttekintésére", "version_announcement_overlay_text_1": "Szia barátom, ennek az alkalmazásnak van egy új verziója: ", @@ -668,6 +676,6 @@ "viewer_remove_from_stack": "Eltávolít a Csoportból", "viewer_stack_use_as_main_asset": "Fő Elemnek Beállít", "viewer_unstack": "Csoport Megszűntetése", - "wifi_name": "WiFi Name", - "your_wifi_name": "Your WiFi name" + "wifi_name": "WiFi Neve", + "your_wifi_name": "A WiFi hálózatod neve" } \ No newline at end of file diff --git a/mobile/assets/i18n/id-ID.json b/mobile/assets/i18n/id-ID.json index 0336c888fb..0ec5fec8da 100644 --- a/mobile/assets/i18n/id-ID.json +++ b/mobile/assets/i18n/id-ID.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Hapus Link Berbagi", "description_input_hint_text": "Tambah deskripsi...", "description_input_submit_error": "Error updating description, check the log for more details", + "description_search": "Hiking day in Sapa", "download_canceled": "Download canceled", "download_complete": "Download complete", "download_enqueue": "Download enqueued", @@ -247,6 +248,7 @@ "download_sucess_android": "The media has been downloaded to DCIM/Immich", "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Tanggal dan Waktu", + "edit_date_time_dialog_search_timezone": "Search timezone...", "edit_date_time_dialog_timezone": "Zona Waktu", "edit_image_title": "Edit", "edit_location_dialog_title": "Lokasi", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Kembali", "login_form_button_text": "Masuk", "login_form_email_hint": "emailmu@email.com", - "login_form_endpoint_hint": "http://ip-server-anda:port/api", + "login_form_endpoint_hint": "http://ip-server-anda:port", "login_form_endpoint_url": "URL Endpoint Server", "login_form_err_http": "Harap tentukan http:// atau https://", "login_form_err_invalid_email": "Email Tidak Valid", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Merek", "search_filter_camera_model": "Model", "search_filter_camera_title": "Select camera type", + "search_filter_contextual": "Search by context", "search_filter_date": "Date", "search_filter_date_interval": "{start} to {end}", "search_filter_date_title": "Select a date range", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "Diarsipkan", "search_filter_display_option_favorite": "Favorit", "search_filter_display_option_not_in_album": "Tidak dalam album", "search_filter_display_options": "Display Options", "search_filter_display_options_title": "Display options", + "search_filter_filename": "Search by file name", "search_filter_location": "Location", "search_filter_location_city": "Kota", "search_filter_location_country": "Negara", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Select media type", "search_filter_media_type_video": "Video", "search_filter_people": "People", + "search_filter_people_hint": "Filter people", "search_filter_people_title": "Select people", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "Kategori", "search_page_favorites": "Favorit", "search_page_motion_photos": "Foto Bergerak", diff --git a/mobile/assets/i18n/it-IT.json b/mobile/assets/i18n/it-IT.json index 44ef8b2adf..61dbe378e0 100644 --- a/mobile/assets/i18n/it-IT.json +++ b/mobile/assets/i18n/it-IT.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Elimina link condiviso", "description_input_hint_text": "Aggiungi descrizione...", "description_input_submit_error": "Errore modificare descrizione, controlli I log per maggiori dettagli", + "description_search": "Hiking day in Sapa", "download_canceled": "Download annullato", "download_complete": "Download completato", "download_enqueue": "Download in coda", @@ -247,6 +248,7 @@ "download_sucess_android": "The media has been downloaded to DCIM/Immich", "download_waiting_to_retry": "In attesa di riprovare", "edit_date_time_dialog_date_time": "Data e ora", + "edit_date_time_dialog_search_timezone": "Search timezone...", "edit_date_time_dialog_timezone": "Fuso orario", "edit_image_title": "Modifica", "edit_location_dialog_title": "Posizione", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Indietro", "login_form_button_text": "Login", "login_form_email_hint": "tuaemail@email.com", - "login_form_endpoint_hint": "http://ip-del-tuo-server:port/api", + "login_form_endpoint_hint": "http://ip-del-tuo-server:port", "login_form_endpoint_url": "Server Endpoint URL", "login_form_err_http": "Per favore specificare http:// o https://", "login_form_err_invalid_email": "Email non valida", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Produttore", "search_filter_camera_model": "Modello", "search_filter_camera_title": "Select camera type", + "search_filter_contextual": "Search by context", "search_filter_date": "Date", "search_filter_date_interval": "{start} to {end}", "search_filter_date_title": "Select a date range", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "Archivia", "search_filter_display_option_favorite": "Preferito", "search_filter_display_option_not_in_album": "Non nell'album", "search_filter_display_options": "Display Options", "search_filter_display_options_title": "Display options", + "search_filter_filename": "Search by file name", "search_filter_location": "Location", "search_filter_location_city": "Città", "search_filter_location_country": "Nazione", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Seleziona il tipo di media", "search_filter_media_type_video": "VIdeo", "search_filter_people": "Persone", + "search_filter_people_hint": "Filter people", "search_filter_people_title": "Seleziona persone", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "Categoria", "search_page_favorites": "Preferiti", "search_page_motion_photos": "Foto in movimento", diff --git a/mobile/assets/i18n/ja-JP.json b/mobile/assets/i18n/ja-JP.json index 4110e5f28e..c22264516d 100644 --- a/mobile/assets/i18n/ja-JP.json +++ b/mobile/assets/i18n/ja-JP.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "共有リンクを消す", "description_input_hint_text": "説明を追加", "description_input_submit_error": "説明の編集に失敗しました。詳細はログを確認してください。", + "description_search": "Hiking day in Sapa", "download_canceled": "ダウンロードがキャンセルされました", "download_complete": "ダウンロード完了", "download_enqueue": "ダウンロード待機中", @@ -247,6 +248,7 @@ "download_sucess_android": "DCIM/Immichに保存されました", "download_waiting_to_retry": "リトライ中", "edit_date_time_dialog_date_time": "日付と時間", + "edit_date_time_dialog_search_timezone": "Search timezone...", "edit_date_time_dialog_timezone": "タイムゾーン", "edit_image_title": "編集", "edit_location_dialog_title": "位置情報", @@ -336,7 +338,7 @@ "login_form_back_button_text": "戻る", "login_form_button_text": "ログイン", "login_form_email_hint": "hoge@email.com", - "login_form_endpoint_hint": "https://example.com:port/api", + "login_form_endpoint_hint": "http://your-server-ip:port", "login_form_endpoint_url": "サーバーのエンドポイントURL", "login_form_err_http": "http://かhttps://かを指定してください", "login_form_err_invalid_email": "メールアドレスが無効です", @@ -455,14 +457,17 @@ "search_filter_camera_make": "メーカー", "search_filter_camera_model": "モデル", "search_filter_camera_title": "カメラの種類を選択", + "search_filter_contextual": "Search by context", "search_filter_date": "撮影日", "search_filter_date_interval": "{start}から{end}まで", "search_filter_date_title": "撮影期間を選択", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "アーカイブ", "search_filter_display_option_favorite": "お気に入り", "search_filter_display_option_not_in_album": "アルバムにありません", "search_filter_display_options": "表示オプション", "search_filter_display_options_title": "表示オプション", + "search_filter_filename": "Search by file name", "search_filter_location": "場所", "search_filter_location_city": "市町村", "search_filter_location_country": "国", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "メディアの種類を選択", "search_filter_media_type_video": "動画", "search_filter_people": "人物", + "search_filter_people_hint": "Filter people", "search_filter_people_title": "人物を選択", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "カテゴリ", "search_page_favorites": "お気に入り", "search_page_motion_photos": "モーションフォト", diff --git a/mobile/assets/i18n/ko-KR.json b/mobile/assets/i18n/ko-KR.json index 0c5906663c..0b1cc93324 100644 --- a/mobile/assets/i18n/ko-KR.json +++ b/mobile/assets/i18n/ko-KR.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "공유 링크 삭제", "description_input_hint_text": "설명 추가...", "description_input_submit_error": "설명을 변경하는 중 문제가 발생했습니다. 자세한 내용은 로그를 참조하세요.", + "description_search": "Hiking day in Sapa", "download_canceled": "다운로드가 취소되었습니다.", "download_complete": "다은로드가 완료되었습니다.", "download_enqueue": "대기열에 다운로드", @@ -247,6 +248,7 @@ "download_sucess_android": "미디어가 DCIM/Immich에 저장되었습니다.", "download_waiting_to_retry": "재시도 대기 중", "edit_date_time_dialog_date_time": "날짜 및 시간", + "edit_date_time_dialog_search_timezone": "Search timezone...", "edit_date_time_dialog_timezone": "시간대", "edit_image_title": "편집", "edit_location_dialog_title": "위치", @@ -336,7 +338,7 @@ "login_form_back_button_text": "뒤로", "login_form_button_text": "로그인", "login_form_email_hint": "youremail@email.com", - "login_form_endpoint_hint": "http://your-server-ip:port/api", + "login_form_endpoint_hint": "http://your-server-ip:port", "login_form_endpoint_url": "서버 엔드포인트 URL", "login_form_err_http": "http:// 또는 https://로 시작해야 합니다.", "login_form_err_invalid_email": "유효하지 않은 이메일", @@ -455,14 +457,17 @@ "search_filter_camera_make": "제조사", "search_filter_camera_model": "모델명", "search_filter_camera_title": "카메라 종류 선택", + "search_filter_contextual": "Search by context", "search_filter_date": "날짜", "search_filter_date_interval": "{start} - {end}", "search_filter_date_title": "날짜 범위 선택", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "보관함", "search_filter_display_option_favorite": "즐겨찾기", "search_filter_display_option_not_in_album": "앨범에 없음", "search_filter_display_options": "표시 옵션", "search_filter_display_options_title": "표시 옵션", + "search_filter_filename": "Search by file name", "search_filter_location": "위치", "search_filter_location_city": "도시", "search_filter_location_country": "국가", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "미디어 종류 선택", "search_filter_media_type_video": "동영상", "search_filter_people": "인물", + "search_filter_people_hint": "Filter people", "search_filter_people_title": "인물 선택", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "분류", "search_page_favorites": "즐겨찾기", "search_page_motion_photos": "모션 포토", diff --git a/mobile/assets/i18n/lt-LT.json b/mobile/assets/i18n/lt-LT.json index f91f5842db..fd628d4692 100644 --- a/mobile/assets/i18n/lt-LT.json +++ b/mobile/assets/i18n/lt-LT.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Delete Shared Link", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", + "description_search": "Hiking day in Sapa", "download_canceled": "Download canceled", "download_complete": "Download complete", "download_enqueue": "Download enqueued", @@ -247,6 +248,7 @@ "download_sucess_android": "The media has been downloaded to DCIM/Immich", "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Date and Time", + "edit_date_time_dialog_search_timezone": "Search timezone...", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", "edit_location_dialog_title": "Location", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Back", "login_form_button_text": "Login", "login_form_email_hint": "youremail@email.com", - "login_form_endpoint_hint": "http://your-server-ip:port/api", + "login_form_endpoint_hint": "http://your-server-ip:port", "login_form_endpoint_url": "Server Endpoint URL", "login_form_err_http": "Please specify http:// or https://", "login_form_err_invalid_email": "Invalid Email", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Make", "search_filter_camera_model": "Model", "search_filter_camera_title": "Select camera type", + "search_filter_contextual": "Search by context", "search_filter_date": "Date", "search_filter_date_interval": "{start} to {end}", "search_filter_date_title": "Select a date range", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "Archive", "search_filter_display_option_favorite": "Favorite", "search_filter_display_option_not_in_album": "Not in album", "search_filter_display_options": "Display Options", "search_filter_display_options_title": "Display options", + "search_filter_filename": "Search by file name", "search_filter_location": "Location", "search_filter_location_city": "City", "search_filter_location_country": "Country", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Select media type", "search_filter_media_type_video": "Video", "search_filter_people": "People", + "search_filter_people_hint": "Filter people", "search_filter_people_title": "Select people", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "Categories", "search_page_favorites": "Favorites", "search_page_motion_photos": "Motion Photos", diff --git a/mobile/assets/i18n/lv-LV.json b/mobile/assets/i18n/lv-LV.json index 692cf6eb26..e7e50b2a47 100644 --- a/mobile/assets/i18n/lv-LV.json +++ b/mobile/assets/i18n/lv-LV.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Dzēst Kopīgošanas saiti", "description_input_hint_text": "Pievienot aprakstu...", "description_input_submit_error": "Atjauninot aprakstu, radās kļūda; papildinformāciju skatiet žurnālā", + "description_search": "Hiking day in Sapa", "download_canceled": "Download canceled", "download_complete": "Download complete", "download_enqueue": "Download enqueued", @@ -247,6 +248,7 @@ "download_sucess_android": "The media has been downloaded to DCIM/Immich", "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Datums un Laiks", + "edit_date_time_dialog_search_timezone": "Search timezone...", "edit_date_time_dialog_timezone": "Laika zona", "edit_image_title": "Edit", "edit_location_dialog_title": "Atrašanās vieta", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Atpakaļ", "login_form_button_text": "Pieteikties", "login_form_email_hint": "jūsuepasts@email.com", - "login_form_endpoint_hint": "http://jūsu-servera-ip:ports/api", + "login_form_endpoint_hint": "http://jūsu-servera-ip:ports", "login_form_endpoint_url": "Servera Galapunkta URL", "login_form_err_http": "Lūdzu norādiet http:// vai https://", "login_form_err_invalid_email": "Nederīgs e-pasts", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Firma", "search_filter_camera_model": "Modelis", "search_filter_camera_title": "Select camera type", + "search_filter_contextual": "Search by context", "search_filter_date": "Date", "search_filter_date_interval": "{start} to {end}", "search_filter_date_title": "Select a date range", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "Arhīvs", "search_filter_display_option_favorite": "Izlase", "search_filter_display_option_not_in_album": "Nav albumā", "search_filter_display_options": "Display Options", "search_filter_display_options_title": "Display options", + "search_filter_filename": "Search by file name", "search_filter_location": "Location", "search_filter_location_city": "Pilsēta", "search_filter_location_country": "Valsts", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Select media type", "search_filter_media_type_video": "Videoklips", "search_filter_people": "People", + "search_filter_people_hint": "Filter people", "search_filter_people_title": "Select people", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "Kategorijas", "search_page_favorites": "Izlase", "search_page_motion_photos": "Kustību Fotoattēli", diff --git a/mobile/assets/i18n/mn-MN.json b/mobile/assets/i18n/mn-MN.json index dbc825279b..33c9ed0440 100644 --- a/mobile/assets/i18n/mn-MN.json +++ b/mobile/assets/i18n/mn-MN.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Delete Shared Link", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", + "description_search": "Hiking day in Sapa", "download_canceled": "Download canceled", "download_complete": "Download complete", "download_enqueue": "Download enqueued", @@ -247,6 +248,7 @@ "download_sucess_android": "The media has been downloaded to DCIM/Immich", "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Date and Time", + "edit_date_time_dialog_search_timezone": "Search timezone...", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", "edit_location_dialog_title": "Location", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Back", "login_form_button_text": "Login", "login_form_email_hint": "youremail@email.com", - "login_form_endpoint_hint": "http://your-server-ip:port/api", + "login_form_endpoint_hint": "http://your-server-ip:port", "login_form_endpoint_url": "Server Endpoint URL", "login_form_err_http": "Please specify http:// or https://", "login_form_err_invalid_email": "Invalid Email", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Make", "search_filter_camera_model": "Model", "search_filter_camera_title": "Select camera type", + "search_filter_contextual": "Search by context", "search_filter_date": "Date", "search_filter_date_interval": "{start} to {end}", "search_filter_date_title": "Select a date range", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "Archive", "search_filter_display_option_favorite": "Favorite", "search_filter_display_option_not_in_album": "Not in album", "search_filter_display_options": "Display Options", "search_filter_display_options_title": "Display options", + "search_filter_filename": "Search by file name", "search_filter_location": "Location", "search_filter_location_city": "City", "search_filter_location_country": "Country", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Select media type", "search_filter_media_type_video": "Video", "search_filter_people": "People", + "search_filter_people_hint": "Filter people", "search_filter_people_title": "Select people", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "Categories", "search_page_favorites": "Favorites", "search_page_motion_photos": "Motion Photos", diff --git a/mobile/assets/i18n/nb-NO.json b/mobile/assets/i18n/nb-NO.json index e1b2a4c477..7b53f34265 100644 --- a/mobile/assets/i18n/nb-NO.json +++ b/mobile/assets/i18n/nb-NO.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Slett delt link", "description_input_hint_text": "Legg til beskrivelse ...", "description_input_submit_error": "Feil ved oppdatering av beskrivelse, sjekk loggen for flere detaljer", + "description_search": "Gåtur i fjellet", "download_canceled": "Nedlasting avbrutt", "download_complete": "Nedlasting fullført", "download_enqueue": "Nedlasting satt i kø", @@ -247,6 +248,7 @@ "download_sucess_android": "Objektet har blitt lastet ned til DCIM/Immich", "download_waiting_to_retry": "Venter på nytt forsøk", "edit_date_time_dialog_date_time": "Dato og tid", + "edit_date_time_dialog_search_timezone": "Søk tidssone...", "edit_date_time_dialog_timezone": "Tidssone", "edit_image_title": "Endre", "edit_location_dialog_title": "Lokasjon", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Tilbake", "login_form_button_text": "Logg inn", "login_form_email_hint": "dinepost@epost.no", - "login_form_endpoint_hint": "http://din-server-ip:port/api", + "login_form_endpoint_hint": "http://din-server-ip:port", "login_form_endpoint_url": "Serverendepunkt-URL", "login_form_err_http": "Vennligst spesifiser http:// eller https://", "login_form_err_invalid_email": "Ugyldig e-postadresse", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Merke", "search_filter_camera_model": "Modell", "search_filter_camera_title": "Velg kameratype", + "search_filter_contextual": "Søk ved hjelp av kontekst", "search_filter_date": "Dato", "search_filter_date_interval": "{start} til {end}", "search_filter_date_title": "Velg ett datoområde", + "search_filter_description": "Søk ved hjelp av beskrivelse", "search_filter_display_option_archive": "Arkiver", "search_filter_display_option_favorite": "Favoritt", "search_filter_display_option_not_in_album": "Ikke i album", "search_filter_display_options": "Visningsvalg", "search_filter_display_options_title": "Visningsvalg", + "search_filter_filename": "Søk etter filnavn", "search_filter_location": "Lokasjon", "search_filter_location_city": "By", "search_filter_location_country": "Land", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Velg medietype", "search_filter_media_type_video": "Video", "search_filter_people": "Mennesker", + "search_filter_people_hint": "Filtrer mennesker", "search_filter_people_title": "Velg mennesker", + "search_no_more_result": "Ingen flere resultater", + "search_no_result": "Ingen resultater funnet, prøv ett annet søkeord eller kombinasjon", "search_page_categories": "Kategorier", "search_page_favorites": "Favoritter", "search_page_motion_photos": "Bevegelige bilder", diff --git a/mobile/assets/i18n/nl-NL.json b/mobile/assets/i18n/nl-NL.json index 3b6a87af5e..d431bc1b6f 100644 --- a/mobile/assets/i18n/nl-NL.json +++ b/mobile/assets/i18n/nl-NL.json @@ -157,7 +157,7 @@ "cache_settings_tile_title": "Lokale opslag", "cache_settings_title": "Cache-instellingen", "cancel": "Annuleren", - "canceled": "Canceled", + "canceled": "Geannuleerd", "change_display_order": "Weergavevolgorde wijzigen", "change_password_form_confirm_password": "Bevestig wachtwoord", "change_password_form_description": "Hallo {name},\n\nDit is ofwel de eerste keer dat je inlogt, of er is een verzoek gedaan om je wachtwoord te wijzigen. Vul hieronder een nieuw wachtwoord in.", @@ -181,7 +181,7 @@ "common_create_new_album": "Nieuw album maken", "common_server_error": "Controleer je netwerkverbinding, zorg ervoor dat de server bereikbaar is en de app/server versies compatibel zijn.", "common_shared": "Gedeeld", - "completed": "Completed", + "completed": "Voltooid", "contextual_search": "Zonsopkomst op het strand", "control_bottom_app_bar_add_to_album": "Aan album toevoegen", "control_bottom_app_bar_album_info": "{} items", @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Verwijder gedeelde link", "description_input_hint_text": "Beschrijving toevoegen...", "description_input_submit_error": "Beschrijving bijwerken mislukt, controleer het logboek voor meer details", + "description_search": "Wandelen in Sapa", "download_canceled": "Download geannuleerd", "download_complete": "Download voltooid", "download_enqueue": "Download in wachtrij", @@ -247,11 +248,12 @@ "download_sucess_android": "Het bestand is gedownload naar DCIM/Immich", "download_waiting_to_retry": "Wachten om opnieuw te proberen", "edit_date_time_dialog_date_time": "Datum en tijd", + "edit_date_time_dialog_search_timezone": "Tijdzone zoeken...", "edit_date_time_dialog_timezone": "Tijdzone", "edit_image_title": "Bewerken", "edit_location_dialog_title": "Locatie", - "end_date": "End date", - "enqueued": "Enqueued", + "end_date": "Einddatum", + "enqueued": "In de wachtrij", "enter_wifi_name": "Voer de WiFi naam in", "error_change_sort_album": "Sorteervolgorde van album wijzigen mislukt", "error_saving_image": "Fout: {}", @@ -267,7 +269,7 @@ "experimental_settings_title": "Experimenteel", "external_network": "Extern netwerk", "external_network_sheet_info": "Als je niet verbonden bent met het opgegeven wifi-netwerk, maakt de app verbinding met de server via de eerst bereikbare URL in de onderstaande lijst, van boven naar beneden", - "failed": "Failed", + "failed": "Mislukt", "favorites": "Favorieten", "favorites_page_no_favorites": "Geen favoriete assets gevonden", "favorites_page_title": "Favorieten", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Terug", "login_form_button_text": "Inloggen", "login_form_email_hint": "jouwemail@email.nl", - "login_form_endpoint_hint": "http://jouw-server-ip:poort/api", + "login_form_endpoint_hint": "http://jouw-server-ip:poort", "login_form_endpoint_url": "Server-URL", "login_form_err_http": "Voer http:// of https:// in", "login_form_err_invalid_email": "Ongeldig e-mailadres", @@ -403,7 +405,7 @@ "notification_permission_list_tile_content": "Geef toestemming om meldingen te versturen.", "notification_permission_list_tile_enable_button": "Meldingen inschakelen", "notification_permission_list_tile_title": "Meldingen toestaan", - "not_selected": "Not selected", + "not_selected": "Niet geselecteerd", "on_this_device": "Op dit apparaat", "partner_list_user_photos": "Foto's van {user}", "partner_list_view_all": "Bekijk alle", @@ -417,7 +419,7 @@ "partner_page_stop_sharing_title": "Stoppen met het delen van je foto's?", "partner_page_title": "Partner", "partners": "Partners", - "paused": "Paused", + "paused": "Gepauzeerd", "people": "Mensen", "permission_onboarding_back": "Terug", "permission_onboarding_continue_anyway": "Toch doorgaan", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Merk", "search_filter_camera_model": "Model", "search_filter_camera_title": "Selecteer cameratype", + "search_filter_contextual": "Zoeken op context", "search_filter_date": "Datum", "search_filter_date_interval": "{start} tot {end}", "search_filter_date_title": "Selecteer datumbereik", + "search_filter_description": "Zoeken op beschrijving", "search_filter_display_option_archive": "Archief", "search_filter_display_option_favorite": "Favoriet", "search_filter_display_option_not_in_album": "Niet in album", "search_filter_display_options": "Weergaveopties", "search_filter_display_options_title": "Weergaveopties", + "search_filter_filename": "Zoeken op bestandsnaam", "search_filter_location": "Locatie", "search_filter_location_city": "Stad", "search_filter_location_country": "Land", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Selecteer mediatype", "search_filter_media_type_video": "Video", "search_filter_people": "Mensen", + "search_filter_people_hint": "Filter mensen", "search_filter_people_title": "Selecteer mensen", + "search_no_more_result": "Geen resultaten meer", + "search_no_result": "Geen resultaten gevonden, probeer een andere zoekterm of combinatie", "search_page_categories": "Categorieën", "search_page_favorites": "Favorieten", "search_page_motion_photos": "Bewegende foto's", @@ -534,8 +542,8 @@ "settings_require_restart": "Start Immich opnieuw op om deze instelling toe te passen", "setting_video_viewer_looping_subtitle": "Schakel in om video's automatisch te herhalen in de detailweergave.", "setting_video_viewer_looping_title": "Herhalen", - "setting_video_viewer_original_video_subtitle": "When streaming a video from the server, play the original even when a transcode is available. May lead to buffering. Videos available locally are played in original quality regardless of this setting.", - "setting_video_viewer_original_video_title": "Force original video", + "setting_video_viewer_original_video_subtitle": "Speel video's altijd in originele kwaliteit af, zelfs als er een getranscodeerd bestand beschikbaar is op de server. Dit kan leiden tot buffering. Video's die lokaal beschikbaar zijn, worden altijd in originele kwaliteit afgespeeld, ongeacht deze instelling.", + "setting_video_viewer_original_video_title": "Forceer originele videokwaliteit", "setting_video_viewer_title": "Video's", "share_add": "Toevoegen", "share_add_photos": "Foto's toevoegen", @@ -554,7 +562,7 @@ "shared_album_section_people_owner_label": "Eigenaar", "shared_album_section_people_title": "MENSEN", "share_dialog_preparing": "Voorbereiden...", - "shared_intent_upload_button_progress_text": "{} / {} Uploaded", + "shared_intent_upload_button_progress_text": "{} / {} geüpload", "shared_link_app_bar_title": "Gedeelde links", "shared_link_clipboard_copied_massage": "Gekopieerd naar klembord", "shared_link_clipboard_text": "Link: {}\nWachtwoord: {}", @@ -610,7 +618,7 @@ "sharing_silver_appbar_create_shared_album": "Gedeeld album maken", "sharing_silver_appbar_shared_links": "Gedeelde links", "sharing_silver_appbar_share_partner": "Delen met partner", - "start_date": "Start date", + "start_date": "Startdatum", "sync": "Synchroniseren", "sync_albums": "Albums synchroniseren", "sync_albums_manual_subtitle": "Synchroniseer alle geüploade video’s en foto’s naar de geselecteerde back-up albums", @@ -649,13 +657,13 @@ "trash_page_select_assets_btn": "Selecteer assets", "trash_page_select_btn": "Selecteren", "trash_page_title": "Prullenbak ({})", - "upload": "Upload", + "upload": "Uploaden", "upload_dialog_cancel": "Annuleren", "upload_dialog_info": "Wil je een backup maken van de geselecteerde asset(s) op de server?", "upload_dialog_ok": "Uploaden", "upload_dialog_title": "Asset uploaden", - "uploading": "Uploading", - "upload_to_immich": "Upload to Immich ({})", + "uploading": "Aan het uploaden", + "upload_to_immich": "Uploaden naar Immich ({})", "use_current_connection": "gebruik huidige verbinding", "validate_endpoint_error": "Vul een geldige URL in", "version_announcement_overlay_ack": "Bevestig", diff --git a/mobile/assets/i18n/pl-PL.json b/mobile/assets/i18n/pl-PL.json index def7599aed..fb575314d0 100644 --- a/mobile/assets/i18n/pl-PL.json +++ b/mobile/assets/i18n/pl-PL.json @@ -157,7 +157,7 @@ "cache_settings_tile_title": "Lokalny magazyn", "cache_settings_title": "Ustawienia Buforowania", "cancel": "Anuluj", - "canceled": "Canceled", + "canceled": "Anulowano", "change_display_order": "Zmień kolejność wyświetlania", "change_password_form_confirm_password": "Potwierdź Hasło", "change_password_form_description": "Cześć {name},\n\nPierwszy raz logujesz się do systemu, albo złożono prośbę o zmianę hasła. Wpisz poniżej nowe hasło.", @@ -181,7 +181,7 @@ "common_create_new_album": "Utwórz nowy album", "common_server_error": "Sprawdź połączenie sieciowe, upewnij się, że serwer jest osiągalny i wersje aplikacji/serwera są kompatybilne.", "common_shared": "Udostępnione", - "completed": "Completed", + "completed": "Ukończono", "contextual_search": "Wschód słońca na plaży", "control_bottom_app_bar_add_to_album": "Dodaj do albumu", "control_bottom_app_bar_album_info": "{} pozycji", @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Usuń udostępniony link", "description_input_hint_text": "Dodaj opis...", "description_input_submit_error": "Błąd aktualizacji opisu, sprawdź dziennik, aby uzyskać więcej szczegółów", + "description_search": "Hiking day in Sapa", "download_canceled": "Pobieranie anulowane", "download_complete": "Pobieranie zakończone", "download_enqueue": "Pobieranie w kolejce", @@ -247,11 +248,12 @@ "download_sucess_android": "Media zostały pobrane do DCIM/Immich", "download_waiting_to_retry": "Oczekiwanie na ponowną próbę", "edit_date_time_dialog_date_time": "Data i godzina", + "edit_date_time_dialog_search_timezone": "Wyszukaj strefę czasową...", "edit_date_time_dialog_timezone": "Strefa czasowa", "edit_image_title": "Edytuj", "edit_location_dialog_title": "Lokalizacja", - "end_date": "End date", - "enqueued": "Enqueued", + "end_date": "Data zakończenia", + "enqueued": "Kolejka", "enter_wifi_name": "Wprowadź nazwę Wi-Fi", "error_change_sort_album": "Nie udało się zmienić kolejności sortowania albumów", "error_saving_image": "Błąd: {}", @@ -267,7 +269,7 @@ "experimental_settings_title": "Eksperymentalny", "external_network": "Sieć zewnętrzna", "external_network_sheet_info": "Jeśli nie korzystasz z preferowanej sieci Wi-Fi, aplikacja połączy się z serwerem za pośrednictwem pierwszego z poniższych adresów URL, do którego może dotrzeć, zaczynając od góry do dołu", - "failed": "Failed", + "failed": "Niepowodzenie", "favorites": "Ulubione", "favorites_page_no_favorites": "Nie znaleziono ulubionych zasobów", "favorites_page_title": "Ulubione", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Cofnij", "login_form_button_text": "Login", "login_form_email_hint": "twojmail@email.com", - "login_form_endpoint_hint": "http://ip-twojego-serwera:port/api", + "login_form_endpoint_hint": "http://ip-twojego-serwera:port", "login_form_endpoint_url": "URL Serwera", "login_form_err_http": "Proszę określić http:// lub https://", "login_form_err_invalid_email": "Niepoprawny Email", @@ -380,7 +382,7 @@ "map_settings_include_show_partners": "Uwzględnij partnerów", "map_settings_only_relative_range": "Zakres dat", "map_settings_only_show_favorites": "Pokaż tylko ulubione", - "map_settings_theme_settings": "Map Theme", + "map_settings_theme_settings": "Motyw mapy", "map_zoom_to_see_photos": "Pomniejsz, aby zobaczyć zdjęcia", "memories_all_caught_up": "Wszystko złapane", "memories_check_back_tomorrow": "Wróć jutro po więcej wspomnień", @@ -403,7 +405,7 @@ "notification_permission_list_tile_content": "Przyznaj uprawnienia, aby włączyć powiadomienia.", "notification_permission_list_tile_enable_button": "Włącz Powiadomienia", "notification_permission_list_tile_title": "Pozwolenie na powiadomienia", - "not_selected": "Not selected", + "not_selected": "Nie wybrano", "on_this_device": "Na tym urządzeniu", "partner_list_user_photos": "{user} zdjęcia", "partner_list_view_all": "Pokaż wszystkie", @@ -413,11 +415,11 @@ "partner_page_partner_add_failed": "Nie udało się dodać partnera", "partner_page_select_partner": "Wybierz partnera", "partner_page_shared_to_title": "Udostępniono", - "partner_page_stop_sharing_content": "{} nie będziesz już mieć dostępu do swoich zdjęć.", - "partner_page_stop_sharing_title": "Przestać udostępniać swoje zdjęcia?", + "partner_page_stop_sharing_content": "{} nie będzie już mieć dostępu do twoich zdjęć.", + "partner_page_stop_sharing_title": "Zatrzymać udostępnianie zdjęć?", "partner_page_title": "Partner", "partners": "Partnerzy", - "paused": "Paused", + "paused": "Wstrzymano", "people": "Ludzie", "permission_onboarding_back": "Cofnij", "permission_onboarding_continue_anyway": "Kontynuuj mimo to", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Make", "search_filter_camera_model": "Model", "search_filter_camera_title": "Wybierz typ kamery", + "search_filter_contextual": "Wyszukaj po kontekście", "search_filter_date": "Data", "search_filter_date_interval": "{start} do {end}", "search_filter_date_title": "Wybierz zakres dat", + "search_filter_description": "Szukaj po opisie", "search_filter_display_option_archive": "Archiwum", "search_filter_display_option_favorite": "Ulubiony", "search_filter_display_option_not_in_album": "Nie w albumie", "search_filter_display_options": "Opcje wyświetlania", "search_filter_display_options_title": "Opcje wyświetlania", + "search_filter_filename": "Wyszukaj po nazwie pliku", "search_filter_location": "Lokalizacja", "search_filter_location_city": "Miasto", "search_filter_location_country": "Kraj", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Wybierz typ multimediów", "search_filter_media_type_video": "Filmy", "search_filter_people": "Osoby", + "search_filter_people_hint": "Filtruj ludzi", "search_filter_people_title": "Wybierz osoby", + "search_no_more_result": "Brak dalszych wyników", + "search_no_result": "Nie znaleziono wyników, spróbuj innego terminu wyszukiwania lub kombinacji", "search_page_categories": "Kategorie", "search_page_favorites": "Ulubione", "search_page_motion_photos": "Zdjęcia ruchome", @@ -534,8 +542,8 @@ "settings_require_restart": "Aby zastosować to ustawienie, uruchom ponownie Immich", "setting_video_viewer_looping_subtitle": "Włącz automatyczne zapętlanie wideo w przeglądarce szczegółów.", "setting_video_viewer_looping_title": "Zapętlenie", - "setting_video_viewer_original_video_subtitle": "When streaming a video from the server, play the original even when a transcode is available. May lead to buffering. Videos available locally are played in original quality regardless of this setting.", - "setting_video_viewer_original_video_title": "Force original video", + "setting_video_viewer_original_video_subtitle": "Podczas strumieniowego przesyłania wideo z serwera odtwarzaj oryginał, nawet jeśli transkodowanie jest dostępne. Może to prowadzić do buforowania. Filmy dostępne lokalnie są odtwarzane w oryginalnej jakości niezależnie od tego ustawienia.", + "setting_video_viewer_original_video_title": "Wymuś oryginalne wideo", "setting_video_viewer_title": "Filmy", "share_add": "Dodaj", "share_add_photos": "Dodaj zdjęcia", @@ -554,7 +562,7 @@ "shared_album_section_people_owner_label": "Właściciel", "shared_album_section_people_title": "LUDZIE", "share_dialog_preparing": "Przygotowywanie...", - "shared_intent_upload_button_progress_text": "{} / {} Uploaded", + "shared_intent_upload_button_progress_text": "{} / {} Przesłano", "shared_link_app_bar_title": "Udostępnione linki", "shared_link_clipboard_copied_massage": "Skopiowane do schowka", "shared_link_clipboard_text": "Link: {}\nHasło: {}", @@ -610,7 +618,7 @@ "sharing_silver_appbar_create_shared_album": "Utwórz współdzielony album", "sharing_silver_appbar_shared_links": "Udostępnione linki", "sharing_silver_appbar_share_partner": "Udostępnij partnerce/partnerowi", - "start_date": "Start date", + "start_date": "Data rozpoczęcia", "sync": "Synchronizuj", "sync_albums": "Synchronizuj albumy", "sync_albums_manual_subtitle": "Zsynchronizuj wszystkie przesłane filmy i zdjęcia z wybranymi albumami kopii zapasowych", @@ -649,13 +657,13 @@ "trash_page_select_assets_btn": "Wybierz zasoby", "trash_page_select_btn": "Wybierz", "trash_page_title": "Kosz({})", - "upload": "Upload", + "upload": "Prześlij", "upload_dialog_cancel": "Anuluj", "upload_dialog_info": "Czy chcesz wykonać kopię zapasową wybranych zasobów na serwerze?", "upload_dialog_ok": "Prześlij", "upload_dialog_title": "Prześlij Zasób", - "uploading": "Uploading", - "upload_to_immich": "Upload to Immich ({})", + "uploading": "Przesyłanie", + "upload_to_immich": "Prześlij do Immich ({})", "use_current_connection": "użyj bieżącego połączenia", "validate_endpoint_error": "Proszę wprowadzić prawidłowy adres URL", "version_announcement_overlay_ack": "Potwierdzam", diff --git a/mobile/assets/i18n/pt-BR.json b/mobile/assets/i18n/pt-BR.json index 6b3ec1dd65..fc9b0c2ac7 100644 --- a/mobile/assets/i18n/pt-BR.json +++ b/mobile/assets/i18n/pt-BR.json @@ -74,7 +74,7 @@ "exif_bottom_sheet_location": "LOCALIZAÇÃO", "login_form_button_text": "Login", "login_form_email_hint": "youremail@email.com", - "login_form_endpoint_hint": "http://your-server-ip:port/api", + "login_form_endpoint_hint": "http://your-server-ip:port", "login_form_endpoint_url": "Server Endpoint URL", "login_form_err_http": "Please specify http:// or https://", "login_form_err_invalid_email": "E-mail inválido", diff --git a/mobile/assets/i18n/pt-PT.json b/mobile/assets/i18n/pt-PT.json index a6afa42402..f3facab7d6 100644 --- a/mobile/assets/i18n/pt-PT.json +++ b/mobile/assets/i18n/pt-PT.json @@ -128,7 +128,7 @@ "backup_controller_page_total_sub": "Todas as fotos e vídeos dos álbuns selecionados", "backup_controller_page_turn_off": "Desativar backup", "backup_controller_page_turn_on": "Ativar backup", - "backup_controller_page_uploading_file_info": "Carregando informações do arquivo", + "backup_controller_page_uploading_file_info": "Enviando arquivo", "backup_err_only_album": "Não é possível remover apenas o álbum", "backup_info_card_assets": "arquivos", "backup_manual_cancelled": "Cancelado", @@ -157,7 +157,7 @@ "cache_settings_tile_title": "Armazenamento local", "cache_settings_title": "Configurações de cache", "cancel": "Cancelar", - "canceled": "Canceled", + "canceled": "Cancelado", "change_display_order": "Mudar ordem de exibição", "change_password_form_confirm_password": "Confirme a senha", "change_password_form_description": "Esta é a primeira vez que você está acessando o sistema ou foi feita uma solicitação para alterar sua senha. Por favor, insira a nova senha abaixo.", @@ -181,7 +181,7 @@ "common_create_new_album": "Criar novo álbum", "common_server_error": "Verifique a sua conexão de rede, certifique-se de que o servidor está acessível e de que as versões da aplicação/servidor são compatíveis.", "common_shared": "Compartilhado", - "completed": "Completed", + "completed": "Sucesso", "contextual_search": "Nascer do sol na praia", "control_bottom_app_bar_add_to_album": "Adicionar ao álbum", "control_bottom_app_bar_album_info": "{} arquivos", @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Excluir link compartilhado", "description_input_hint_text": "Adicionar descrição...", "description_input_submit_error": "Erro ao atualizar a descrição, verifique o registo para obter mais detalhes", + "description_search": "Dia de caminhada no Ibirapuera", "download_canceled": "Cancelado", "download_complete": "Sucesso", "download_enqueue": "Na fila", @@ -247,11 +248,12 @@ "download_sucess_android": "O arquivo foi baixado na pasta DCIM/Immich", "download_waiting_to_retry": "Tentando novamente", "edit_date_time_dialog_date_time": "Data e Hora", + "edit_date_time_dialog_search_timezone": "Pesquisar fuso horário...", "edit_date_time_dialog_timezone": "Fuso horário", "edit_image_title": "Editar", "edit_location_dialog_title": "Localização", - "end_date": "End date", - "enqueued": "Enqueued", + "end_date": "Data final", + "enqueued": "Na fila", "enter_wifi_name": "Digite o nome do Wi-Fi", "error_change_sort_album": "Falha ao mudar a ordem de exibição", "error_saving_image": "Erro: {}", @@ -267,7 +269,7 @@ "experimental_settings_title": "Experimental", "external_network": "Rede externa", "external_network_sheet_info": "Quando não estiver na rede Wi-Fi especificada, o aplicativo irá se conectar usando a primeira URL abaixo que obtiver sucesso, começando do topo da lista para baixo.", - "failed": "Failed", + "failed": "Falhou", "favorites": "Favoritos", "favorites_page_no_favorites": "Nenhum favorito encontrado", "favorites_page_title": "Favoritos", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Voltar", "login_form_button_text": "Login", "login_form_email_hint": "seuemail@email.com", - "login_form_endpoint_hint": "http://ip-do-seu-servidor:porta/api", + "login_form_endpoint_hint": "http://ip-do-seu-servidor:porta", "login_form_endpoint_url": "URL do servidor", "login_form_err_http": "Por favor especifique http:// ou https://", "login_form_err_invalid_email": "Email Inválido", @@ -403,7 +405,7 @@ "notification_permission_list_tile_content": "Dar permissões para ativar notificações", "notification_permission_list_tile_enable_button": "Ativar notificações", "notification_permission_list_tile_title": "Permissão de notificações", - "not_selected": "Not selected", + "not_selected": "Não selecionado", "on_this_device": "Neste dispositivo", "partner_list_user_photos": "Fotos de {user}", "partner_list_view_all": "Ver tudo", @@ -417,7 +419,7 @@ "partner_page_stop_sharing_title": "Parar de compartilhar as suas fotos?", "partner_page_title": "Parceiro", "partners": "Parceiros", - "paused": "Paused", + "paused": "Pausado", "people": "Pessoas", "permission_onboarding_back": "Voltar", "permission_onboarding_continue_anyway": "Continuar mesmo assim", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Fabricante", "search_filter_camera_model": "Modelo", "search_filter_camera_title": "Selecione o tipo de câmera", + "search_filter_contextual": "Pesquisar por contexto", "search_filter_date": "Data", "search_filter_date_interval": "{start} até {end}", "search_filter_date_title": "Selecione a data", + "search_filter_description": "Pesquisar por descrição", "search_filter_display_option_archive": "Arquivado", "search_filter_display_option_favorite": "Favorito", "search_filter_display_option_not_in_album": "Fora de álbum", "search_filter_display_options": "Opções de exibição", "search_filter_display_options_title": "Opções de exibição", + "search_filter_filename": "Pesquisar por nome do arquivo", "search_filter_location": "Localização", "search_filter_location_city": "Cidade", "search_filter_location_country": "País", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Selecione o tipo da mídia", "search_filter_media_type_video": "Vídeo", "search_filter_people": "Pessoas", + "search_filter_people_hint": "Filtrar pessoas", "search_filter_people_title": "Selecionar pessoas", + "search_no_more_result": "Sem mais resultados", + "search_no_result": "Nenhum resultado encontrado, tente pesquisar por algo diferente", "search_page_categories": "Categorias", "search_page_favorites": "Favoritos", "search_page_motion_photos": "Fotos com movimento", @@ -554,7 +562,7 @@ "shared_album_section_people_owner_label": "Dono", "shared_album_section_people_title": "PESSOAS", "share_dialog_preparing": "Preparando...", - "shared_intent_upload_button_progress_text": "{} / {} Uploaded", + "shared_intent_upload_button_progress_text": "Enviados {} de {}", "shared_link_app_bar_title": "Links compartilhados", "shared_link_clipboard_copied_massage": "Copiado para a área de transferência", "shared_link_clipboard_text": "Link: {}\nPassword: {}", @@ -610,7 +618,7 @@ "sharing_silver_appbar_create_shared_album": "Criar álbum partilhado", "sharing_silver_appbar_shared_links": "Links compartilhados", "sharing_silver_appbar_share_partner": "Compartilhar com parceiro", - "start_date": "Start date", + "start_date": "Data inicial", "sync": "Sincronizar", "sync_albums": "Sincronizar álbuns", "sync_albums_manual_subtitle": "Sincronizar todas as fotos e vídeos enviados para o álbum de backup selecionado", @@ -649,13 +657,13 @@ "trash_page_select_assets_btn": "Selecionar arquivos", "trash_page_select_btn": "Selecionar", "trash_page_title": "Lixeira ({})", - "upload": "Upload", + "upload": "Enviar", "upload_dialog_cancel": "Cancelar", "upload_dialog_info": "Deseja fazer o backup dos arquivos selecionados no servidor?", "upload_dialog_ok": "Enviar", "upload_dialog_title": "Enviar arquivo", - "uploading": "Uploading", - "upload_to_immich": "Upload to Immich ({})", + "uploading": "Enviando", + "upload_to_immich": "Enviar para o Immich ({})", "use_current_connection": "usar conexão atual", "validate_endpoint_error": "Digite uma URL válida", "version_announcement_overlay_ack": "Entendi", diff --git a/mobile/assets/i18n/ro-RO.json b/mobile/assets/i18n/ro-RO.json index 4ba950841a..6fd74df427 100644 --- a/mobile/assets/i18n/ro-RO.json +++ b/mobile/assets/i18n/ro-RO.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Șterge link distribuire", "description_input_hint_text": "Adaugă descriere...", "description_input_submit_error": "Eroare actualizare descriere, verifică log-urile pentru mai multe detalii", + "description_search": "Hiking day in Sapa", "download_canceled": "Download canceled", "download_complete": "Download complete", "download_enqueue": "Download enqueued", @@ -247,6 +248,7 @@ "download_sucess_android": "The media has been downloaded to DCIM/Immich", "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Dată și Oră", + "edit_date_time_dialog_search_timezone": "Search timezone...", "edit_date_time_dialog_timezone": "Fus orar", "edit_image_title": "Edit", "edit_location_dialog_title": "Locație", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Înapoi", "login_form_button_text": "Conectare", "login_form_email_hint": "email-ultau@email.com", - "login_form_endpoint_hint": "http://ip-server:port/api", + "login_form_endpoint_hint": "http://ip-server:port", "login_form_endpoint_url": "URL-ul destinației sever-ului", "login_form_err_http": "Te rugăm specifică http:// sau https://", "login_form_err_invalid_email": "Email invalid", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Make", "search_filter_camera_model": "Model", "search_filter_camera_title": "Select camera type", + "search_filter_contextual": "Search by context", "search_filter_date": "Date", "search_filter_date_interval": "{start} to {end}", "search_filter_date_title": "Select a date range", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "Archive", "search_filter_display_option_favorite": "Favorite", "search_filter_display_option_not_in_album": "Not in album", "search_filter_display_options": "Display Options", "search_filter_display_options_title": "Display options", + "search_filter_filename": "Search by file name", "search_filter_location": "Location", "search_filter_location_city": "City", "search_filter_location_country": "Country", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Select media type", "search_filter_media_type_video": "Video", "search_filter_people": "People", + "search_filter_people_hint": "Filter people", "search_filter_people_title": "Select people", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "Categorii", "search_page_favorites": "Favorite", "search_page_motion_photos": "Fotografii în mișcare", diff --git a/mobile/assets/i18n/ru-RU.json b/mobile/assets/i18n/ru-RU.json index 5dc2884f30..7180b5f209 100644 --- a/mobile/assets/i18n/ru-RU.json +++ b/mobile/assets/i18n/ru-RU.json @@ -7,7 +7,7 @@ "action_common_select": "Выбрать", "action_common_update": "Обновить", "add_a_name": "Добавить имя", - "add_endpoint": "Add endpoint", + "add_endpoint": "Добавить адрес", "add_to_album_bottom_sheet_added": "Добавлено в {album}", "add_to_album_bottom_sheet_already_exists": "Уже в {album}", "advanced_settings_log_level_title": "Уровень логирования:", @@ -66,12 +66,12 @@ "assets_restored_successfully": "{} объект(ы) успешно восстановлен(ы)", "assets_trashed": "{} объект(ы) помещен(ы) в корзину", "assets_trashed_from_server": "{} объект(ы) помещен(ы) в корзину на сервере Immich", - "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", + "asset_viewer_settings_subtitle": "Настройка параметров отображения", "asset_viewer_settings_title": "Просмотр изображений", - "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", - "automatic_endpoint_switching_title": "Automatic URL switching", - "background_location_permission": "Background location permission", - "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", + "automatic_endpoint_switching_subtitle": "Подключаться локально по выбранной сети и использовать альтернативные адреса в ином случае", + "automatic_endpoint_switching_title": "Автоматическая смена URL", + "background_location_permission": "Доступ к местоположению в фоне", + "background_location_permission_content": "Чтобы считывать имя Wi-Fi сети в фоне, приложению *всегда* необходим доступ к точному местоположению устройства", "backup_album_selection_page_albums_device": "Альбомы на устройстве ({})", "backup_album_selection_page_albums_tap": "Нажмите, чтобы включить,\nнажмите дважды, чтобы исключить", "backup_album_selection_page_assets_scatter": "Ваши изображения и видео могут находиться в разных альбомах. Вы можете выбрать, какие альбомы включить, а какие исключить из резервного копирования.", @@ -137,7 +137,7 @@ "backup_manual_success": "Успешно", "backup_manual_title": "Статус загрузки", "backup_options_page_title": "Резервное копирование", - "backup_setting_subtitle": "Manage background and foreground upload settings", + "backup_setting_subtitle": "Настройка активного и фонового резервного копирования", "cache_settings_album_thumbnails": "Миниатюры страниц библиотеки ({} объектов)", "cache_settings_clear_cache_button": "Очистить кэш", "cache_settings_clear_cache_button_title": "Очищает кэш приложения. Это негативно повлияет на производительность, пока кэш не будет создан заново.", @@ -156,17 +156,17 @@ "cache_settings_tile_subtitle": "Управление локальным хранилищем", "cache_settings_tile_title": "Локальное хранилище", "cache_settings_title": "Настройки кэширования", - "cancel": "Cancel", - "canceled": "Canceled", - "change_display_order": "Change display order", + "cancel": "Отменить", + "canceled": "Отменено", + "change_display_order": "Изменить порядок отображения", "change_password_form_confirm_password": "Подтвердите пароль", "change_password_form_description": "Привет, {name}!\n\nЛибо ваш первый вход в систему, либо вы запросили смену пароля. Пожалуйста, введите новый пароль ниже.", "change_password_form_new_password": "Новый пароль", "change_password_form_password_mismatch": "Пароли не совпадают", "change_password_form_reenter_new_password": "Повторно введите новый пароль", - "check_corrupt_asset_backup": "Check for corrupt asset backups", - "check_corrupt_asset_backup_button": "Perform check", - "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", + "check_corrupt_asset_backup": "Проверка поврежденных резервных копий", + "check_corrupt_asset_backup_button": "Проверить", + "check_corrupt_asset_backup_description": "Проводите проверку только через Wi-Fi и только после резервного копирования всех объектов. Эта операция может занять несколько минут", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Введите пароль", "client_cert_import": "Импорт", @@ -181,7 +181,7 @@ "common_create_new_album": "Создать новый альбом", "common_server_error": "Пожалуйста, проверьте подключение к сети и убедитесь, что ваш сервер доступен, а версии приложения и сервера — совместимы.", "common_shared": "Общие", - "completed": "Completed", + "completed": "Завершено", "contextual_search": "Восход солнца на пляже", "control_bottom_app_bar_add_to_album": "Добавить в альбом", "control_bottom_app_bar_album_info": "{} элементов", @@ -198,7 +198,7 @@ "control_bottom_app_bar_favorite": "В избранное", "control_bottom_app_bar_share": "Поделиться", "control_bottom_app_bar_share_to": "Поделиться", - "control_bottom_app_bar_stack": "Стек", + "control_bottom_app_bar_stack": "Группировать", "control_bottom_app_bar_trash_from_immich": "В корзину", "control_bottom_app_bar_unarchive": "Восстановить", "control_bottom_app_bar_unfavorite": "Удалить из избранного", @@ -213,7 +213,7 @@ "crop": "Обрезать", "curated_location_page_title": "Места", "curated_object_page_title": "Предметы", - "current_server_address": "Current server address", + "current_server_address": "Текущий адрес сервера", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Удалить публичную ссылку", "description_input_hint_text": "Добавить описание...", "description_input_submit_error": "Не удалось обновить описание, проверьте логи, чтобы узнать причину", + "description_search": "Hiking day in Sapa", "download_canceled": "Загрузка отменена", "download_complete": "Загрузка окончена", "download_enqueue": "Загрузка в очереди", @@ -247,13 +248,14 @@ "download_sucess_android": "Медиафайлы загружены в DCIM/Immich", "download_waiting_to_retry": "Ожидание повторной попытки", "edit_date_time_dialog_date_time": "Дата и время", + "edit_date_time_dialog_search_timezone": "Поиск временной зоны...", "edit_date_time_dialog_timezone": "Часовой пояс", "edit_image_title": "Редактировать", "edit_location_dialog_title": "Местоположение", - "end_date": "End date", - "enqueued": "Enqueued", - "enter_wifi_name": "Enter WiFi name", - "error_change_sort_album": "Failed to change album sort order", + "end_date": "Дата окончания", + "enqueued": "Занесено в очередь", + "enter_wifi_name": "Введите имя Wi-Fi сети", + "error_change_sort_album": "Не удалось изменить порядок сортировки альбома", "error_saving_image": "Ошибка: {}", "exif_bottom_sheet_description": "Добавить описание...", "exif_bottom_sheet_details": "ПОДРОБНОСТИ", @@ -265,16 +267,16 @@ "experimental_settings_new_asset_list_title": "Включить экспериментальную сетку фотографий", "experimental_settings_subtitle": "Используйте на свой страх и риск!", "experimental_settings_title": "Экспериментальные функции", - "external_network": "External network", - "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", - "failed": "Failed", + "external_network": "Внешняя сеть", + "external_network_sheet_info": "Когда устройство не подключено к выбранной Wi-Fi сети, приложение будет пытаться подключиться к серверу по адресам ниже, сверху вниз, до успешного подключения", + "failed": "Ошибка", "favorites": "Избранное", "favorites_page_no_favorites": "В избранном сейчас пусто", "favorites_page_title": "Избранное", "filename_search": "Имя или расширение файла", "filter": "Фильтр", - "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", - "grant_permission": "Grant permission", + "get_wifiname_error": "Не удалось получить имя Wi-Fi сети. Убедитесь, что вы подключены к сети и предоставили приложению необходимые разрешения", + "grant_permission": "Предоставить разрешение", "haptic_feedback_switch": "Включить тактильную отдачу", "haptic_feedback_title": "Тактильная отдача", "header_settings_add_header_tip": "Добавить заголовок", @@ -320,10 +322,10 @@ "library_page_sort_most_oldest_photo": "Старые фото", "library_page_sort_most_recent_photo": "Последние фото", "library_page_sort_title": "Название альбома", - "local_network": "Local network", - "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", - "location_permission": "Location permission", - "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", + "local_network": "Локальная сеть", + "local_network_sheet_info": "Приложение будет подключаться к серверу по этому адресу, когда устройство подключено к выбранной Wi-Fi сети", + "location_permission": "Доступ к местоположению", + "location_permission_content": "Чтобы считывать имя Wi-Fi сети, приложению необходим доступ к точному местоположению устройства", "location_picker_choose_on_map": "Выбрать на карте", "location_picker_latitude": "Широта", "location_picker_latitude_error": "Укажите правильную широту", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Назад", "login_form_button_text": "Войти", "login_form_email_hint": "youremail@email.com", - "login_form_endpoint_hint": "http://your-server-ip:port/api", + "login_form_endpoint_hint": "http://your-server-ip:port", "login_form_endpoint_url": "URL-aдрес сервера", "login_form_err_http": "Пожалуйста, укажите протокол http:// или https://", "login_form_err_invalid_email": "Некорректный адрес электронной почты", @@ -393,8 +395,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Невозможно изменить дату файлов только для чтения, пропуск", "multiselect_grid_edit_gps_err_read_only": "Невозможно изменить местоположение файлов только для чтения, пропуск", "my_albums": "Мои альбомы", - "networking_settings": "Networking", - "networking_subtitle": "Manage the server endpoint settings", + "networking_settings": "Сеть", + "networking_subtitle": "Настройка подключения к серверу", "no_assets_to_show": "Медиа отсутствуют", "no_name": "Без имени", "notification_permission_dialog_cancel": "Отмена", @@ -403,7 +405,7 @@ "notification_permission_list_tile_content": "Предоставьте разрешение на включение уведомлений", "notification_permission_list_tile_enable_button": "Включить уведомления", "notification_permission_list_tile_title": "Разрешение на уведомление", - "not_selected": "Not selected", + "not_selected": "Не выбрано", "on_this_device": "На этом устройстве", "partner_list_user_photos": "Фотографии {user}", "partner_list_view_all": "Посмотреть все", @@ -417,7 +419,7 @@ "partner_page_stop_sharing_title": "Закрыть доступ к вашим фото?", "partner_page_title": "Партнёр", "partners": "Партнёры", - "paused": "Paused", + "paused": "Приостановлено", "people": "Люди", "permission_onboarding_back": "Назад", "permission_onboarding_continue_anyway": "Все равно продолжить", @@ -430,7 +432,7 @@ "permission_onboarding_permission_limited": "Доступ к файлам ограничен. Чтобы Immich мог создавать резервные копии и управлять вашей галереей, пожалуйста, предоставьте приложению разрешение на доступ к \"Фото и видео\" в настройках.", "permission_onboarding_request": "Приложению необходимо разрешение на доступ к вашим фото и видео", "places": "Места", - "preferences_settings_subtitle": "Manage the app's preferences", + "preferences_settings_subtitle": "Настройка внешнего вида", "preferences_settings_title": "Параметры", "profile_drawer_app_logs": "Журнал", "profile_drawer_client_out_of_date_major": "Версия мобильного приложения устарела. Пожалуйста, обновите его.", @@ -445,7 +447,7 @@ "profile_drawer_trash": "Корзина", "recently_added": "Недавно добавленные", "recently_added_page_title": "Недавно добавленные", - "save": "Save", + "save": "Сохранить", "save_to_gallery": "Сохранить в галерею", "scaffold_body_error_occurred": "Возникла ошибка", "search_albums": "Поиск альбома", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Производитель", "search_filter_camera_model": "Модель", "search_filter_camera_title": "Выберите тип камеры", + "search_filter_contextual": "Search by context", "search_filter_date": "Дата", "search_filter_date_interval": "{start} — {end}", "search_filter_date_title": "Выберите промежуток", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "Архив", "search_filter_display_option_favorite": "Избранное", "search_filter_display_option_not_in_album": "Не в альбоме", "search_filter_display_options": "Настройки отображения", "search_filter_display_options_title": "Настройки отображения", + "search_filter_filename": "Search by file name", "search_filter_location": "Место", "search_filter_location_city": "Город", "search_filter_location_country": "Страна", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Выберите тип медиа", "search_filter_media_type_video": "Видео", "search_filter_people": "Люди", + "search_filter_people_hint": "Filter people", "search_filter_people_title": "Выберите людей", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "Категории", "search_page_favorites": "Избранное", "search_page_motion_photos": "Динамические фото", @@ -491,7 +499,7 @@ "search_page_places": "Места", "search_page_recently_added": "Недавно добавленные", "search_page_screenshots": "Снимки экрана", - "search_page_search_photos_videos": "Search for your photos and videos", + "search_page_search_photos_videos": "Поиск по фото и видео", "search_page_selfies": "Селфи", "search_page_things": "Предметы", "search_page_videos": "Видео", @@ -504,7 +512,7 @@ "select_additional_user_for_sharing_page_suggestions": "Предложения", "select_user_for_sharing_page_err_album": "Не удалось создать альбом", "select_user_for_sharing_page_share_suggestions": "Предложения", - "server_endpoint": "Server Endpoint", + "server_endpoint": "Адрес сервера", "server_info_box_app_version": "Версия приложения", "server_info_box_latest_release": "Последняя версия", "server_info_box_server_url": "URL сервера", @@ -516,7 +524,7 @@ "setting_image_viewer_preview_title": "Загружать уменьшенное изображение", "setting_image_viewer_title": "Изображения", "setting_languages_apply": "Применить", - "setting_languages_subtitle": "Change the app's language", + "setting_languages_subtitle": "Изменить язык приложения", "setting_languages_title": "Язык", "setting_notifications_notify_failures_grace_period": "Уведомлять об ошибках фонового резервного копирования: {}", "setting_notifications_notify_hours": "{} ч.", @@ -534,8 +542,8 @@ "settings_require_restart": "Пожалуйста, перезапустите приложение, чтобы изменения вступили в силу", "setting_video_viewer_looping_subtitle": "Включить циклическое воспроизведение видео", "setting_video_viewer_looping_title": "Циклическое воспроизведение", - "setting_video_viewer_original_video_subtitle": "When streaming a video from the server, play the original even when a transcode is available. May lead to buffering. Videos available locally are played in original quality regardless of this setting.", - "setting_video_viewer_original_video_title": "Force original video", + "setting_video_viewer_original_video_subtitle": "При воспроизведении видео с сервера загружать оригинал, даже если доступна транскодированная версия. Может привести к буферизации. Не влияет на локальные видео", + "setting_video_viewer_original_video_title": "Только оригинальное видео", "setting_video_viewer_title": "Видео", "share_add": "Добавить", "share_add_photos": "Добавить фото", @@ -554,7 +562,7 @@ "shared_album_section_people_owner_label": "Владелец", "shared_album_section_people_title": "ЛЮДИ", "share_dialog_preparing": "Подготовка...", - "shared_intent_upload_button_progress_text": "{} / {} Uploaded", + "shared_intent_upload_button_progress_text": "{} / {} Загружено", "shared_link_app_bar_title": "Публичные ссылки", "shared_link_clipboard_copied_massage": "Скопировано в буфер обмена", "shared_link_clipboard_text": "Ссылка: {}\nПароль: {}", @@ -610,7 +618,7 @@ "sharing_silver_appbar_create_shared_album": "Создать общий альбом", "sharing_silver_appbar_shared_links": "Публичные ссылки", "sharing_silver_appbar_share_partner": "Поделиться с партнёром", - "start_date": "Start date", + "start_date": "Дата начала", "sync": "Синхронизировать", "sync_albums": "Синхронизировать альбомы", "sync_albums_manual_subtitle": "Синхронизировать все загруженные фото и видео в выбранные альбомы для резервного копирования", @@ -649,15 +657,15 @@ "trash_page_select_assets_btn": "Выбранные объекты", "trash_page_select_btn": "Выбрать", "trash_page_title": "Корзина ({})", - "upload": "Upload", + "upload": "Загрузить", "upload_dialog_cancel": "Отмена", "upload_dialog_info": "Хотите создать резервную копию выбранных объектов на сервере?", "upload_dialog_ok": "Загрузить", "upload_dialog_title": "Загрузить объект", - "uploading": "Uploading", - "upload_to_immich": "Upload to Immich ({})", - "use_current_connection": "use current connection", - "validate_endpoint_error": "Please enter a valid URL", + "uploading": "Загружается", + "upload_to_immich": "Загрузка в Immich ({})", + "use_current_connection": "Использовать текущее подключение", + "validate_endpoint_error": "Введите корректный URL", "version_announcement_overlay_ack": "Понятно", "version_announcement_overlay_release_notes": "примечания к выпуску", "version_announcement_overlay_text_1": "Привет, друг! Вышла новая версия", @@ -665,9 +673,9 @@ "version_announcement_overlay_text_3": " и убедитесь, что ваши настройки docker-compose и .env обновлены, особенно если вы используете WatchTower или любой другой механизм, который автоматически обновляет сервер.", "version_announcement_overlay_title": "Доступна новая версия сервера \uD83C\uDF89", "videos": "Видео", - "viewer_remove_from_stack": "Удалить из стека", + "viewer_remove_from_stack": "Убрать из группы", "viewer_stack_use_as_main_asset": "Использовать в качестве основного объекта", - "viewer_unstack": "Разобрать стек", - "wifi_name": "WiFi Name", - "your_wifi_name": "Your WiFi name" + "viewer_unstack": "Разгруппировать", + "wifi_name": "Имя сети", + "your_wifi_name": "Имя вашей Wi-Fi сети" } \ No newline at end of file diff --git a/mobile/assets/i18n/sk-SK.json b/mobile/assets/i18n/sk-SK.json index 61ec344c37..aadda3948c 100644 --- a/mobile/assets/i18n/sk-SK.json +++ b/mobile/assets/i18n/sk-SK.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Odstrániť zdieľaný odkaz", "description_input_hint_text": "Pridať popis...", "description_input_submit_error": "Chyba pri aktualizovaní popisu, zobrazte log pre viac detailov", + "description_search": "Hiking day in Sapa", "download_canceled": "Download canceled", "download_complete": "Download complete", "download_enqueue": "Download enqueued", @@ -247,6 +248,7 @@ "download_sucess_android": "The media has been downloaded to DCIM/Immich", "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Dátum a čas", + "edit_date_time_dialog_search_timezone": "Search timezone...", "edit_date_time_dialog_timezone": "Časové pásmo", "edit_image_title": "Edit", "edit_location_dialog_title": "Poloha", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Späť", "login_form_button_text": "Prihlásiť sa", "login_form_email_hint": "tvojmail@email.com", - "login_form_endpoint_hint": "http://ip-tvojho-servera:port/api", + "login_form_endpoint_hint": "http://ip-tvojho-servera:port", "login_form_endpoint_url": "URL adresa servera", "login_form_err_http": "Prosím, uveďte http:// alebo https://", "login_form_err_invalid_email": "Neplatný e-mail", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Spraviť", "search_filter_camera_model": "Model", "search_filter_camera_title": "Select camera type", + "search_filter_contextual": "Search by context", "search_filter_date": "Date", "search_filter_date_interval": "{start} to {end}", "search_filter_date_title": "Select a date range", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "Archív", "search_filter_display_option_favorite": "Obľúbené", "search_filter_display_option_not_in_album": "Mimo albumu", "search_filter_display_options": "Display Options", "search_filter_display_options_title": "Display options", + "search_filter_filename": "Search by file name", "search_filter_location": "Location", "search_filter_location_city": "Mesto", "search_filter_location_country": "Oblasť", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Select media type", "search_filter_media_type_video": "Video", "search_filter_people": "People", + "search_filter_people_hint": "Filter people", "search_filter_people_title": "Select people", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "Kategórie", "search_page_favorites": "Obľúbené", "search_page_motion_photos": "Pohyblivé fotky", diff --git a/mobile/assets/i18n/sl-SI.json b/mobile/assets/i18n/sl-SI.json index 0d750e28a4..0a29bec6fa 100644 --- a/mobile/assets/i18n/sl-SI.json +++ b/mobile/assets/i18n/sl-SI.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Izbriši povezavo skupne rabe", "description_input_hint_text": "Dodaj opis ...", "description_input_submit_error": "Napaka pri posodabljanju opisa, preverite dnevnik za več podrobnosti", + "description_search": "Pohodniški dan v Sapi", "download_canceled": "Prenos preklican", "download_complete": "Prenos končan", "download_enqueue": "Prenos v čakalni vrsti", @@ -247,6 +248,7 @@ "download_sucess_android": "Medij je bil prenesen v DCIM/Immich", "download_waiting_to_retry": "Čakam na ponovni poskus", "edit_date_time_dialog_date_time": "Datum in ura", + "edit_date_time_dialog_search_timezone": "Išči časovni pas...", "edit_date_time_dialog_timezone": "Časovni pas", "edit_image_title": "Urejanje", "edit_location_dialog_title": "Lokacija", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Nazaj", "login_form_button_text": "Prijava", "login_form_email_hint": "vašemail@email.com", - "login_form_endpoint_hint": "http://ip-vašega-strežnika:vrata/api", + "login_form_endpoint_hint": "http://ip-vašega-strežnika:vrata", "login_form_endpoint_url": "URL končne točke strežnika", "login_form_err_http": "Navedi http:// ali https://", "login_form_err_invalid_email": "Neveljaven e-poštni naslov", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Izdelava", "search_filter_camera_model": "Model", "search_filter_camera_title": "Izberi vrsto fotoaparata", + "search_filter_contextual": "Iskanje po kontekstu", "search_filter_date": "Datum", "search_filter_date_interval": "{start} do {end}", "search_filter_date_title": "Izberi časovno obdobje", + "search_filter_description": "Iskanje po opisu", "search_filter_display_option_archive": "Arhiv", "search_filter_display_option_favorite": "Priljubljen", "search_filter_display_option_not_in_album": "Ni v albumu", "search_filter_display_options": "Možnosti zaslona", "search_filter_display_options_title": "Možnosti prikaza", + "search_filter_filename": "Iskanje po imenu datoteke", "search_filter_location": "Lokacija", "search_filter_location_city": "Mesto", "search_filter_location_country": "Država", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Izberi vrsto medija", "search_filter_media_type_video": "Video", "search_filter_people": "Ljudje", + "search_filter_people_hint": "Filter po ljudeh", "search_filter_people_title": "Izberi osebe", + "search_no_more_result": "Ni več rezultatov", + "search_no_result": "Ni rezultatov, poskusite z drugim iskalnim izrazom ali kombinacijo", "search_page_categories": "Kategorije", "search_page_favorites": "Priljubljene", "search_page_motion_photos": "Fotografije v gibanju", diff --git a/mobile/assets/i18n/sr-Cyrl.json b/mobile/assets/i18n/sr-Cyrl.json index f91f5842db..fd628d4692 100644 --- a/mobile/assets/i18n/sr-Cyrl.json +++ b/mobile/assets/i18n/sr-Cyrl.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Delete Shared Link", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", + "description_search": "Hiking day in Sapa", "download_canceled": "Download canceled", "download_complete": "Download complete", "download_enqueue": "Download enqueued", @@ -247,6 +248,7 @@ "download_sucess_android": "The media has been downloaded to DCIM/Immich", "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Date and Time", + "edit_date_time_dialog_search_timezone": "Search timezone...", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", "edit_location_dialog_title": "Location", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Back", "login_form_button_text": "Login", "login_form_email_hint": "youremail@email.com", - "login_form_endpoint_hint": "http://your-server-ip:port/api", + "login_form_endpoint_hint": "http://your-server-ip:port", "login_form_endpoint_url": "Server Endpoint URL", "login_form_err_http": "Please specify http:// or https://", "login_form_err_invalid_email": "Invalid Email", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Make", "search_filter_camera_model": "Model", "search_filter_camera_title": "Select camera type", + "search_filter_contextual": "Search by context", "search_filter_date": "Date", "search_filter_date_interval": "{start} to {end}", "search_filter_date_title": "Select a date range", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "Archive", "search_filter_display_option_favorite": "Favorite", "search_filter_display_option_not_in_album": "Not in album", "search_filter_display_options": "Display Options", "search_filter_display_options_title": "Display options", + "search_filter_filename": "Search by file name", "search_filter_location": "Location", "search_filter_location_city": "City", "search_filter_location_country": "Country", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Select media type", "search_filter_media_type_video": "Video", "search_filter_people": "People", + "search_filter_people_hint": "Filter people", "search_filter_people_title": "Select people", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "Categories", "search_page_favorites": "Favorites", "search_page_motion_photos": "Motion Photos", diff --git a/mobile/assets/i18n/sr-Latn.json b/mobile/assets/i18n/sr-Latn.json index 47d43570bb..e0f8edc97b 100644 --- a/mobile/assets/i18n/sr-Latn.json +++ b/mobile/assets/i18n/sr-Latn.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Delete Shared Link", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", + "description_search": "Hiking day in Sapa", "download_canceled": "Download canceled", "download_complete": "Download complete", "download_enqueue": "Download enqueued", @@ -247,6 +248,7 @@ "download_sucess_android": "The media has been downloaded to DCIM/Immich", "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Date and Time", + "edit_date_time_dialog_search_timezone": "Search timezone...", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", "edit_location_dialog_title": "Location", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Back", "login_form_button_text": "Prijavi se", "login_form_email_hint": "vašemail@email.com", - "login_form_endpoint_hint": "http://ip-vašeg-servera:port/api", + "login_form_endpoint_hint": "http://ip-vašeg-servera:port", "login_form_endpoint_url": "URL Servera", "login_form_err_http": "Dopiši http:// ili https://", "login_form_err_invalid_email": "Nevažeći Email", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Make", "search_filter_camera_model": "Model", "search_filter_camera_title": "Select camera type", + "search_filter_contextual": "Search by context", "search_filter_date": "Date", "search_filter_date_interval": "{start} to {end}", "search_filter_date_title": "Select a date range", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "Archive", "search_filter_display_option_favorite": "Favorite", "search_filter_display_option_not_in_album": "Not in album", "search_filter_display_options": "Display Options", "search_filter_display_options_title": "Display options", + "search_filter_filename": "Search by file name", "search_filter_location": "Location", "search_filter_location_city": "City", "search_filter_location_country": "Country", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Select media type", "search_filter_media_type_video": "Video", "search_filter_people": "People", + "search_filter_people_hint": "Filter people", "search_filter_people_title": "Select people", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "Categories", "search_page_favorites": "Favorites", "search_page_motion_photos": "Motion Photos", diff --git a/mobile/assets/i18n/sv-FI.json b/mobile/assets/i18n/sv-FI.json index f91f5842db..fd628d4692 100644 --- a/mobile/assets/i18n/sv-FI.json +++ b/mobile/assets/i18n/sv-FI.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Delete Shared Link", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", + "description_search": "Hiking day in Sapa", "download_canceled": "Download canceled", "download_complete": "Download complete", "download_enqueue": "Download enqueued", @@ -247,6 +248,7 @@ "download_sucess_android": "The media has been downloaded to DCIM/Immich", "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Date and Time", + "edit_date_time_dialog_search_timezone": "Search timezone...", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", "edit_location_dialog_title": "Location", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Back", "login_form_button_text": "Login", "login_form_email_hint": "youremail@email.com", - "login_form_endpoint_hint": "http://your-server-ip:port/api", + "login_form_endpoint_hint": "http://your-server-ip:port", "login_form_endpoint_url": "Server Endpoint URL", "login_form_err_http": "Please specify http:// or https://", "login_form_err_invalid_email": "Invalid Email", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Make", "search_filter_camera_model": "Model", "search_filter_camera_title": "Select camera type", + "search_filter_contextual": "Search by context", "search_filter_date": "Date", "search_filter_date_interval": "{start} to {end}", "search_filter_date_title": "Select a date range", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "Archive", "search_filter_display_option_favorite": "Favorite", "search_filter_display_option_not_in_album": "Not in album", "search_filter_display_options": "Display Options", "search_filter_display_options_title": "Display options", + "search_filter_filename": "Search by file name", "search_filter_location": "Location", "search_filter_location_city": "City", "search_filter_location_country": "Country", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Select media type", "search_filter_media_type_video": "Video", "search_filter_people": "People", + "search_filter_people_hint": "Filter people", "search_filter_people_title": "Select people", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "Categories", "search_page_favorites": "Favorites", "search_page_motion_photos": "Motion Photos", diff --git a/mobile/assets/i18n/sv-SE.json b/mobile/assets/i18n/sv-SE.json index 8e71b89e65..75ed10f25c 100644 --- a/mobile/assets/i18n/sv-SE.json +++ b/mobile/assets/i18n/sv-SE.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Ta Bort Delad Länk", "description_input_hint_text": "Lägg till beskrivning...", "description_input_submit_error": "Fel vid uppdatering av beskrivning, se loggen för fler detaljer", + "description_search": "Hiking day in Sapa", "download_canceled": "Nedladdning avbruten", "download_complete": "Nedladdning slutförd", "download_enqueue": "Nedladdning köad", @@ -247,6 +248,7 @@ "download_sucess_android": "Media har laddats ner till DCIM/Immich", "download_waiting_to_retry": "Väntar på omförsök", "edit_date_time_dialog_date_time": "Datum och Tid", + "edit_date_time_dialog_search_timezone": "Sök efter tidszon...", "edit_date_time_dialog_timezone": "Tidszon", "edit_image_title": "Redigera", "edit_location_dialog_title": "Plats", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Bakåt", "login_form_button_text": "Logga in", "login_form_email_hint": "din.email@email.com", - "login_form_endpoint_hint": "http://din-server-ip:port/api", + "login_form_endpoint_hint": "http://din-server-ip:port", "login_form_endpoint_url": "Server Endpoint URL", "login_form_err_http": "Var god ange http:// eller https://", "login_form_err_invalid_email": "Ogiltig email", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Tillverkare", "search_filter_camera_model": "Modell", "search_filter_camera_title": "Välj kameratyp", + "search_filter_contextual": "Search by context", "search_filter_date": "Datum", "search_filter_date_interval": "{start} till {end}", "search_filter_date_title": "Välj datumintervall", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "Arkiv", "search_filter_display_option_favorite": "Favorit", "search_filter_display_option_not_in_album": "Ej i album", "search_filter_display_options": "Visningsalternativ", "search_filter_display_options_title": "Visningsalternativ", + "search_filter_filename": "Search by file name", "search_filter_location": "Plats", "search_filter_location_city": "Stad", "search_filter_location_country": "Land", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Välj mediatyp", "search_filter_media_type_video": "Videor", "search_filter_people": "Personer", + "search_filter_people_hint": "Filter people", "search_filter_people_title": "Välj personer", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "Kategorier", "search_page_favorites": "Favoriter", "search_page_motion_photos": "Rörelsefoton", diff --git a/mobile/assets/i18n/th-TH.json b/mobile/assets/i18n/th-TH.json index a17fe0ea12..1475286c64 100644 --- a/mobile/assets/i18n/th-TH.json +++ b/mobile/assets/i18n/th-TH.json @@ -181,7 +181,7 @@ "common_create_new_album": "สร้างอัลบั้มใหม่", "common_server_error": "กรุณาตรวจสอบการเชื่อมต่ออินเทอร์เน็ต ให้แน่ใจว่าเซิร์ฟเวอร์สามารถเข้าถึงได้ และเวอร์ชันแอพกับเซิร์ฟเวอร์เข้ากันได้", "common_shared": "แชร์", - "completed": "Completed", + "completed": "สำเร็จ", "contextual_search": "Sunrise on the beach", "control_bottom_app_bar_add_to_album": "เพิ่มลงอัลบั้ม", "control_bottom_app_bar_album_info": "{} รายการ", @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "ลบลิงก์ที่แชร์", "description_input_hint_text": "เพื่มรายละเอียด...", "description_input_submit_error": "อัพเดตรายละเอียดผิดพลาด ตรวจสอบ log เพื่อรายละเอียดเพิ่มเติม", + "description_search": "Hiking day in Sapa", "download_canceled": "Download canceled", "download_complete": "Download complete", "download_enqueue": "Download enqueued", @@ -247,6 +248,7 @@ "download_sucess_android": "The media has been downloaded to DCIM/Immich", "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "วันและเวลา", + "edit_date_time_dialog_search_timezone": "Search timezone...", "edit_date_time_dialog_timezone": "เขดเวลา", "edit_image_title": "Edit", "edit_location_dialog_title": "ตำแหน่ง", @@ -336,7 +338,7 @@ "login_form_back_button_text": "กลับ", "login_form_button_text": "เข้าสู่ระบบ", "login_form_email_hint": "อีเมลคุณ@อีเมล.com", - "login_form_endpoint_hint": "http://ไอพีเชอร์ฟเวอร์คุณ:พอร์ต/api", + "login_form_endpoint_hint": "http://ไอพีเชอร์ฟเวอร์คุณ:พอร์ต", "login_form_endpoint_url": "URL ปลายทางของเซิร์ฟเวอร์", "login_form_err_http": "โปรดระบุ http:// หรือ https://", "login_form_err_invalid_email": "อีเมลไม่ถูกต้อง", @@ -455,14 +457,17 @@ "search_filter_camera_make": "ยี่ห้อ", "search_filter_camera_model": "รุ่น", "search_filter_camera_title": "Select camera type", + "search_filter_contextual": "Search by context", "search_filter_date": "Date", "search_filter_date_interval": "{start} to {end}", "search_filter_date_title": "Select a date range", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "เก็บถาวร", "search_filter_display_option_favorite": "รายการโปรด", "search_filter_display_option_not_in_album": "ไม่อยู่ในอัลบั้ม", "search_filter_display_options": "Display Options", "search_filter_display_options_title": "Display options", + "search_filter_filename": "Search by file name", "search_filter_location": "Location", "search_filter_location_city": "เมือง", "search_filter_location_country": "ประเทศ", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Select media type", "search_filter_media_type_video": "วิดีโอ", "search_filter_people": "People", + "search_filter_people_hint": "Filter people", "search_filter_people_title": "Select people", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "หมวดหมู่", "search_page_favorites": "รายการโปรด", "search_page_motion_photos": "ภาพเคลื่อนไหว", diff --git a/mobile/assets/i18n/tr-TR.json b/mobile/assets/i18n/tr-TR.json index d8d939966e..0bfb75dbc2 100644 --- a/mobile/assets/i18n/tr-TR.json +++ b/mobile/assets/i18n/tr-TR.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Paylaşılan Bağlantı Sil", "description_input_hint_text": "Açıklama ekle...", "description_input_submit_error": "Açıklama güncellenirken hata oluştu, daha fazla ayrıntı için günlüğü kontrol edin", + "description_search": "Hiking day in Sapa", "download_canceled": "İndirme iptal edildi", "download_complete": "İndirme tamamlandı", "download_enqueue": "İndirme sıraya alındı", @@ -247,6 +248,7 @@ "download_sucess_android": "Medya DCIM/Immich klasörüne indirildi", "download_waiting_to_retry": "Yeniden denemek için bekleniyor", "edit_date_time_dialog_date_time": "Tarih ve Saat", + "edit_date_time_dialog_search_timezone": "Search timezone...", "edit_date_time_dialog_timezone": "Zaman Dilimi", "edit_image_title": "Düzenle", "edit_location_dialog_title": "Konum", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Geri", "login_form_button_text": "Giriş", "login_form_email_hint": "mail@adresiniz.com", - "login_form_endpoint_hint": "http://sunucu-ip:port/api", + "login_form_endpoint_hint": "http://sunucu-ip:port", "login_form_endpoint_url": "Sunucu Uç Nokta URL", "login_form_err_http": "Lütfen http:// veya https:// olarak belirtin", "login_form_err_invalid_email": "Geçersiz E-posta", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Marka", "search_filter_camera_model": "Model", "search_filter_camera_title": "Kamera tipi seç", + "search_filter_contextual": "Search by context", "search_filter_date": "Tarih", "search_filter_date_interval": "{start} -> {end}", "search_filter_date_title": "Tarih aralığı seç", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "Arşiv", "search_filter_display_option_favorite": "Favori", "search_filter_display_option_not_in_album": "Albümde değil", "search_filter_display_options": "Görüntü Seçenekleri", "search_filter_display_options_title": "Görüntü seçenekleri", + "search_filter_filename": "Search by file name", "search_filter_location": "Konum", "search_filter_location_city": "Şehir", "search_filter_location_country": "Ülke", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Medya türü seç", "search_filter_media_type_video": "Video", "search_filter_people": "Kişiler", + "search_filter_people_hint": "Filter people", "search_filter_people_title": "Kişi seç", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "Kategoriler", "search_page_favorites": "Favoriler", "search_page_motion_photos": "Canlı Fotoğraflar", diff --git a/mobile/assets/i18n/uk-UA.json b/mobile/assets/i18n/uk-UA.json index 571511ad83..2b619099da 100644 --- a/mobile/assets/i18n/uk-UA.json +++ b/mobile/assets/i18n/uk-UA.json @@ -7,10 +7,10 @@ "action_common_select": "Вибрати", "action_common_update": "Оновити", "add_a_name": "Додати ім'я", - "add_endpoint": "Add endpoint", + "add_endpoint": "Додати кінцеву точку", "add_to_album_bottom_sheet_added": "Додано до {album}", "add_to_album_bottom_sheet_already_exists": "Вже є в {album}", - "advanced_settings_log_level_title": "Log level: {}", + "advanced_settings_log_level_title": "Рівень логування: {}", "advanced_settings_prefer_remote_subtitle": "Деякі пристрої вельми повільно завантажують мініатюри із елементів на пристрої. Активуйте для завантаження віддалених мініатюр натомість.", "advanced_settings_prefer_remote_title": "Перевага віддаленим зображенням", "advanced_settings_proxy_headers_subtitle": "Визначте заголовки проксі-сервера, які Immich має надсилати з кожним мережевим запитом.", @@ -66,12 +66,12 @@ "assets_restored_successfully": "{} елемент(и) успішно відновлено", "assets_trashed": "{} елемент(и) поміщено до кошика", "assets_trashed_from_server": "{} елемент(и) поміщено до кошика на сервері Immich", - "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", + "asset_viewer_settings_subtitle": "Керуйте налаштуваннями переглядача галереї", "asset_viewer_settings_title": "Переглядач зображень", - "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", - "automatic_endpoint_switching_title": "Automatic URL switching", - "background_location_permission": "Background location permission", - "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", + "automatic_endpoint_switching_subtitle": "Підключатися локально через зазначену Wi-Fi мережу, коли це можливо, і використовувати альтернативні з'єднання в інших випадках", + "automatic_endpoint_switching_title": "Автоматичне перемикання URL", + "background_location_permission": "Дозвіл до місцезнаходження у фоні", + "background_location_permission_content": "Щоб перемикати мережі у фоновому режимі, Immich має *завжди* мати доступ до точної геолокації, щоб зчитувати назву Wi-Fi мережі", "backup_album_selection_page_albums_device": "Альбоми на пристрої ({})", "backup_album_selection_page_albums_tap": "Торкніться, щоб включити,\nторкніться двічі, щоб виключити", "backup_album_selection_page_assets_scatter": "Елементи можуть належати до кількох альбомів водночас. Таким чином, альбоми можуть бути включені або вилучені під час резервного копіювання.", @@ -119,7 +119,7 @@ "backup_controller_page_remainder_sub": "Решта знімків та відео для резервного копіювання з вибраних", "backup_controller_page_select": "Вибрати", "backup_controller_page_server_storage": "Сховище сервера", - "backup_controller_page_start_backup": "Почати Резервне Копіювання", + "backup_controller_page_start_backup": "Почати резервне копіювання", "backup_controller_page_status_off": "Автоматичне резервне копіювання в активному режимі вимкнено", "backup_controller_page_status_on": "Автоматичне резервне копіювання в активному режимі ввімкнено", "backup_controller_page_storage_format": "{} із {} спожито", @@ -137,7 +137,7 @@ "backup_manual_success": "Успіх", "backup_manual_title": "Стан завантаження", "backup_options_page_title": "Резервне копіювання", - "backup_setting_subtitle": "Manage background and foreground upload settings", + "backup_setting_subtitle": "Управління налаштуваннями завантаження у фоновому та активному режимі", "cache_settings_album_thumbnails": "Мініатюри сторінок бібліотеки ({} елементи)", "cache_settings_clear_cache_button": "Очистити кеш", "cache_settings_clear_cache_button_title": "Очищає кеш програми. Це суттєво знизить продуктивність програми, доки кеш не буде перебудовано.", @@ -156,17 +156,17 @@ "cache_settings_tile_subtitle": "Керування поведінкою локального сховища", "cache_settings_tile_title": "Локальне сховище", "cache_settings_title": "Налаштування кешування", - "cancel": "Cancel", - "canceled": "Canceled", - "change_display_order": "Change display order", + "cancel": "Скасувати", + "canceled": "Скасовано", + "change_display_order": "Змінити порядок відображення", "change_password_form_confirm_password": "Підтвердити пароль", "change_password_form_description": "Привіт {name},\n\nВи або або вперше входите у систему, або було зроблено запит на зміну вашого пароля. \nВведіть ваш новий пароль.", "change_password_form_new_password": "Новий пароль", "change_password_form_password_mismatch": "Паролі не співпадають", "change_password_form_reenter_new_password": "Повторіть новий пароль", - "check_corrupt_asset_backup": "Check for corrupt asset backups", - "check_corrupt_asset_backup_button": "Perform check", - "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", + "check_corrupt_asset_backup": "Перевірити на пошкоджені резервні копії активів", + "check_corrupt_asset_backup_button": "Виконати перевірку", + "check_corrupt_asset_backup_description": "Запустіть цю перевірку лише через Wi-Fi та після того, як всі активи будуть завантажені на сервер. Процес може зайняти кілька хвилин.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Введіть пароль", "client_cert_import": "Імпорт", @@ -181,7 +181,7 @@ "common_create_new_album": "Створити новий альбом", "common_server_error": "Будь ласка, перевірте з'єднання, переконайтеся, що сервер доступний і версія програми/сервера сумісна.", "common_shared": "Спільні", - "completed": "Completed", + "completed": "Завершено", "contextual_search": "Схід сонця на пляжі", "control_bottom_app_bar_add_to_album": "Додати у альбом", "control_bottom_app_bar_album_info": "{} елементи", @@ -199,7 +199,7 @@ "control_bottom_app_bar_share": "Поділитися", "control_bottom_app_bar_share_to": "Поділитися", "control_bottom_app_bar_stack": "Стек", - "control_bottom_app_bar_trash_from_immich": "Перемістити до кошика", + "control_bottom_app_bar_trash_from_immich": "До кошика", "control_bottom_app_bar_unarchive": "Розархівувати", "control_bottom_app_bar_unfavorite": "Видалити з улюблених", "control_bottom_app_bar_upload": "Завантажити", @@ -213,7 +213,7 @@ "crop": "Кадрувати", "curated_location_page_title": "Місця", "curated_object_page_title": "Речі", - "current_server_address": "Current server address", + "current_server_address": "Поточна адреса сервера", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Видалити спільне посилання", "description_input_hint_text": "Додати опис...", "description_input_submit_error": "Помилка оновлення опису, перевірте логи для подробиць", + "description_search": "День походу в Сапі", "download_canceled": "Завантаження скасовано", "download_complete": "Завантаження закінчено", "download_enqueue": "Завантаження поставлено в чергу", @@ -247,13 +248,14 @@ "download_sucess_android": "Медіафайли завантажено в DCIM/Immich", "download_waiting_to_retry": "Очікування повторної спроби", "edit_date_time_dialog_date_time": "Дата і час", + "edit_date_time_dialog_search_timezone": "Пошук часової зони...", "edit_date_time_dialog_timezone": "Часовий пояс", "edit_image_title": "Редагувати", "edit_location_dialog_title": "Місцезнаходження", - "end_date": "End date", - "enqueued": "Enqueued", - "enter_wifi_name": "Enter WiFi name", - "error_change_sort_album": "Failed to change album sort order", + "end_date": "Дата завершення", + "enqueued": "У черзі", + "enter_wifi_name": "Введіть назву WiFi", + "error_change_sort_album": "Не вдалося змінити порядок сортування альбому", "error_saving_image": "Помилка: {}", "exif_bottom_sheet_description": "Додати опис...", "exif_bottom_sheet_details": "ПОДРОБИЦІ", @@ -265,16 +267,16 @@ "experimental_settings_new_asset_list_title": "Експериментальний макет знімків", "experimental_settings_subtitle": "На власний ризик!", "experimental_settings_title": "Експериментальні", - "external_network": "External network", - "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", - "failed": "Failed", + "external_network": "Зовнішня мережа", + "external_network_sheet_info": "Коли ви не підключені до переважної мережі WiFi, додаток підключатиметься до сервера через першу з наведених нижче URL-адрес, яку він зможе досягти, починаючи зверху вниз", + "failed": "Не вдалося", "favorites": "Вибране", "favorites_page_no_favorites": "Немає улюблених елементів", "favorites_page_title": "Улюблені", "filename_search": "Ім'я або розширення файлу", "filter": "Фільтр", - "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", - "grant_permission": "Grant permission", + "get_wifiname_error": "Не вдалося отримати назву Wi-Fi. Переконайтеся, що ви надали необхідні дозволи та підключені до Wi-Fi мережі", + "grant_permission": "Надати дозвіл", "haptic_feedback_switch": "Увімкнути тактильну віддачу", "haptic_feedback_title": "Тактильна віддача", "header_settings_add_header_tip": "Додати заголовок", @@ -320,10 +322,10 @@ "library_page_sort_most_oldest_photo": "Найдавніші фото", "library_page_sort_most_recent_photo": "Найновіші фото", "library_page_sort_title": "Назва альбому", - "local_network": "Local network", - "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", - "location_permission": "Location permission", - "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", + "local_network": "Локальна мережа", + "local_network_sheet_info": "Додаток підключатиметься до сервера через цей URL, коли використовується вказана Wi-Fi мережа", + "location_permission": "Дозвіл до місцезнаходження", + "location_permission_content": "Щоб перемикати мережі у фоновому режимі, Immich має *завжди* мати доступ до точної геолокації, щоб зчитувати назву Wi-Fi мережі", "location_picker_choose_on_map": "Обрати на мапі", "location_picker_latitude": "Широта", "location_picker_latitude_error": "Вкажіть дійсну широту", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Назад", "login_form_button_text": "Увійти", "login_form_email_hint": "youremail@email.com", - "login_form_endpoint_hint": "http://your-server-ip:port/api", + "login_form_endpoint_hint": "http://your-server-ip:port", "login_form_endpoint_url": "Адреса точки досупу на сервері", "login_form_err_http": "Вкажіть http:// або https://", "login_form_err_invalid_email": "Хибний імейл", @@ -393,8 +395,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Неможливо редагувати дату елементів лише для читання, пропущено", "multiselect_grid_edit_gps_err_read_only": "Неможливо редагувати місцезнаходження елементів лише для читання, пропущено", "my_albums": "Мої альбоми", - "networking_settings": "Networking", - "networking_subtitle": "Manage the server endpoint settings", + "networking_settings": "Мережеві налаштування", + "networking_subtitle": "Керування налаштуваннями кінцевої точки сервера", "no_assets_to_show": "Елементи відсутні", "no_name": "Без імені", "notification_permission_dialog_cancel": "Скасувати", @@ -403,7 +405,7 @@ "notification_permission_list_tile_content": "Надати дозвіл для сповіщень.", "notification_permission_list_tile_enable_button": "Увімкнути Сповіщення", "notification_permission_list_tile_title": "Дозвіл на Сповіщення", - "not_selected": "Not selected", + "not_selected": "Не вибрано", "on_this_device": "На цьому пристрої", "partner_list_user_photos": "Фотографії {user}", "partner_list_view_all": "Переглянути усі", @@ -417,7 +419,7 @@ "partner_page_stop_sharing_title": "Припинити надання ваших знімків?", "partner_page_title": "Партнер", "partners": "\nПартнери", - "paused": "Paused", + "paused": "Призупинено", "people": "Люди", "permission_onboarding_back": "Назад", "permission_onboarding_continue_anyway": "Все одно продовжити", @@ -430,7 +432,7 @@ "permission_onboarding_permission_limited": "Обмежений доступ. Аби дозволити Immich резервне копіювання та керування вашою галереєю, надайте доступ до знімків та відео у Налаштуваннях", "permission_onboarding_request": "Immich потребує доступу до ваших знімків та відео.", "places": "Місця", - "preferences_settings_subtitle": "Manage the app's preferences", + "preferences_settings_subtitle": "Керування налаштуваннями додатку", "preferences_settings_title": "Параметри", "profile_drawer_app_logs": "Журнал", "profile_drawer_client_out_of_date_major": "Мобільний додаток застарів. Будь ласка, оновіть до останньої мажорної версії.", @@ -445,7 +447,7 @@ "profile_drawer_trash": "Кошик", "recently_added": "Нещодавно додані", "recently_added_page_title": "Нещодавні", - "save": "Save", + "save": "Зберегти", "save_to_gallery": "Зберегти в галерею", "scaffold_body_error_occurred": "Виникла помилка", "search_albums": "Пошук альбому", @@ -455,14 +457,17 @@ "search_filter_camera_make": "Виробник", "search_filter_camera_model": "Модель", "search_filter_camera_title": "Виберіть тип камери", + "search_filter_contextual": "Пошук за контекстом", "search_filter_date": "Дата", "search_filter_date_interval": "{start} до {end}", "search_filter_date_title": "Виберіть діапазон дат", + "search_filter_description": "Пошук за описом", "search_filter_display_option_archive": "Архів", "search_filter_display_option_favorite": "Улюблені", "search_filter_display_option_not_in_album": "Не в альбомі", "search_filter_display_options": "Параметри відображення", "search_filter_display_options_title": "Параметри відображення", + "search_filter_filename": "Пошук за назвою файлу", "search_filter_location": "Місцезнаходження", "search_filter_location_city": "Місто", "search_filter_location_country": "Країна", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Виберіть тип носія", "search_filter_media_type_video": "Відео", "search_filter_people": "Люди", + "search_filter_people_hint": "Фільтрувати за людьми", "search_filter_people_title": "Виберіть людей", + "search_no_more_result": "Більше результатів немає", + "search_no_result": "Результатів не знайдено, спробуйте інший запит або комбінацію", "search_page_categories": "Категорії", "search_page_favorites": "Улюблені", "search_page_motion_photos": "Рухомі знімки", @@ -491,7 +499,7 @@ "search_page_places": "Місця", "search_page_recently_added": "Нещодавно додані", "search_page_screenshots": "Знімки екрану", - "search_page_search_photos_videos": "Search for your photos and videos", + "search_page_search_photos_videos": "Шукайте ваші фото та відео", "search_page_selfies": "Селфі", "search_page_things": "Речі", "search_page_videos": "Відео", @@ -504,7 +512,7 @@ "select_additional_user_for_sharing_page_suggestions": "Пропозиції", "select_user_for_sharing_page_err_album": "Не вдалося створити альбом", "select_user_for_sharing_page_share_suggestions": "Пропозиції", - "server_endpoint": "Server Endpoint", + "server_endpoint": "Кінцева точка сервера", "server_info_box_app_version": "Версія додатка", "server_info_box_latest_release": "Остання версія", "server_info_box_server_url": "URL сервера", @@ -516,7 +524,7 @@ "setting_image_viewer_preview_title": "Завантажувати зображення попереднього перегляду", "setting_image_viewer_title": "Зображення", "setting_languages_apply": "Застосувати", - "setting_languages_subtitle": "Change the app's language", + "setting_languages_subtitle": "Змінити мову додатку", "setting_languages_title": "Мова", "setting_notifications_notify_failures_grace_period": "Повідомити про помилки фонового резервного копіювання: {}", "setting_notifications_notify_hours": "{} годин", @@ -534,8 +542,8 @@ "settings_require_restart": "Перезавантажте програму для застосування цього налаштування", "setting_video_viewer_looping_subtitle": "Увімкнути циклічне відтворення відео", "setting_video_viewer_looping_title": "Циклічне відтворення", - "setting_video_viewer_original_video_subtitle": "When streaming a video from the server, play the original even when a transcode is available. May lead to buffering. Videos available locally are played in original quality regardless of this setting.", - "setting_video_viewer_original_video_title": "Force original video", + "setting_video_viewer_original_video_subtitle": "При трансляції відео з сервера відтворювати оригінал, навіть якщо доступна транскодування. Може призвести до буферизації. Відео, доступні локально, відтворюються в оригінальній якості, незважаючи на це налаштування.", + "setting_video_viewer_original_video_title": "Примусово відтворювати оригінальне відео", "setting_video_viewer_title": "Відео", "share_add": "Додати", "share_add_photos": "Додати знімки", @@ -554,7 +562,7 @@ "shared_album_section_people_owner_label": "Власник", "shared_album_section_people_title": "ЛЮДИ", "share_dialog_preparing": "Підготовка...", - "shared_intent_upload_button_progress_text": "{} / {} Uploaded", + "shared_intent_upload_button_progress_text": "{} / {} Завантажено", "shared_link_app_bar_title": "Спільні посилання", "shared_link_clipboard_copied_massage": "Скопійовано в буфер обміну", "shared_link_clipboard_text": "Посилання: {}\nПароль: {}", @@ -610,7 +618,7 @@ "sharing_silver_appbar_create_shared_album": "Створити спільний альбом", "sharing_silver_appbar_shared_links": "Спільні посилання", "sharing_silver_appbar_share_partner": "Поділитися з партнером", - "start_date": "Start date", + "start_date": "Дата початку", "sync": "Синхронізувати", "sync_albums": "Синхронізувати альбоми", "sync_albums_manual_subtitle": "Синхронізувати всі завантажені фото та відео у вибрані альбоми для резервного копіювання", @@ -649,15 +657,15 @@ "trash_page_select_assets_btn": "Вибрані елементи", "trash_page_select_btn": "Вибрати", "trash_page_title": "Кошик ({})", - "upload": "Upload", + "upload": "Завантажити", "upload_dialog_cancel": "Скасувати", "upload_dialog_info": "Бажаєте створити резервну копію вибраних елементів на сервері?", "upload_dialog_ok": "Завантажити", "upload_dialog_title": "Завантажити Елементи", - "uploading": "Uploading", - "upload_to_immich": "Upload to Immich ({})", - "use_current_connection": "use current connection", - "validate_endpoint_error": "Please enter a valid URL", + "uploading": "Завантаження", + "upload_to_immich": "Завантажити в Immich ({})", + "use_current_connection": "використовувати поточне підключення", + "validate_endpoint_error": "Будь ласка, введіть дійсну URL-адресу", "version_announcement_overlay_ack": "Прийняти", "version_announcement_overlay_release_notes": "примітки до випуску", "version_announcement_overlay_text_1": "Вітаємо, є новий випуск ", @@ -668,6 +676,6 @@ "viewer_remove_from_stack": "Видалити зі стеку", "viewer_stack_use_as_main_asset": "Використовувати як основний елементи", "viewer_unstack": "Розібрати стек", - "wifi_name": "WiFi Name", - "your_wifi_name": "Your WiFi name" + "wifi_name": "Назва WiFi", + "your_wifi_name": "Ваша назва WiFi" } \ No newline at end of file diff --git a/mobile/assets/i18n/vi-VN.json b/mobile/assets/i18n/vi-VN.json index 05310acdd2..2cc78d24ba 100644 --- a/mobile/assets/i18n/vi-VN.json +++ b/mobile/assets/i18n/vi-VN.json @@ -6,8 +6,8 @@ "action_common_save": "Lưu", "action_common_select": "Chọn", "action_common_update": "Cập nhật", - "add_a_name": "Add a name", - "add_endpoint": "Add endpoint", + "add_a_name": "Đặt tên", + "add_endpoint": "Thêm địa chỉ máy chủ", "add_to_album_bottom_sheet_added": "Thêm vào {album}", "add_to_album_bottom_sheet_already_exists": "Đã có sẵn trong {album}", "advanced_settings_log_level_title": "Phân loại nhật ký: {}", @@ -23,7 +23,7 @@ "advanced_settings_troubleshooting_title": "Xử lý sự cố", "album_info_card_backup_album_excluded": "ĐÃ BỎ QUA", "album_info_card_backup_album_included": "ĐÃ THÊM", - "albums": "Albums", + "albums": "Album", "album_thumbnail_card_item": "1 mục", "album_thumbnail_card_items": "{} mục", "album_thumbnail_card_shared": " · Chia sẻ", @@ -45,7 +45,7 @@ "app_bar_signout_dialog_content": "Bạn có muốn đăng xuất?", "app_bar_signout_dialog_ok": "Có", "app_bar_signout_dialog_title": "Đăng xuất", - "archived": "Archived", + "archived": "Lưu trữ", "archive_page_no_archived_assets": "Không tìm thấy ảnh đã lưu trữ", "archive_page_title": "Kho lưu trữ ({})", "asset_action_delete_err_read_only": "Không thể xoá ảnh chỉ có quyền đọc, bỏ qua", @@ -66,12 +66,12 @@ "assets_restored_successfully": "Đã khôi phục {} mục thành công", "assets_trashed": "Đã chuyển {} mục vào thùng rác", "assets_trashed_from_server": "Đã chuyển {} mục từ máy chủ Immich vào thùng rác", - "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", + "asset_viewer_settings_subtitle": "Quản lý hiển thị thư viện", "asset_viewer_settings_title": "Trình xem ảnh", - "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", - "automatic_endpoint_switching_title": "Automatic URL switching", - "background_location_permission": "Background location permission", - "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", + "automatic_endpoint_switching_subtitle": "Kết nối nội bộ qua Wi-Fi được chỉ định khi kết nối được và sử dụng các kết nối thay thế ở nơi khác", + "automatic_endpoint_switching_title": "Tự động chuyển đổi địa chỉ máy chủ", + "background_location_permission": "Quyền truy cập vị trí ở nền", + "background_location_permission_content": "Để chuyển đổi mạng khi chạy ở chế độ nền, Immich *luôn* phải có quyền truy cập vị trí chính xác để ứng dụng có thể đọc tên mạng Wi-Fi", "backup_album_selection_page_albums_device": "Album trên thiết bị ({})", "backup_album_selection_page_albums_tap": "Nhấn để chọn, nhấn đúp để bỏ qua", "backup_album_selection_page_assets_scatter": "Ảnh có thể có trong nhiều album khác nhau. Trong quá trình sao lưu, bạn có thể chọn để sao lưu tất cả các album hoặc chỉ một số album nhất định.", @@ -137,7 +137,7 @@ "backup_manual_success": "Thành công", "backup_manual_title": "Trạng thái tải lên", "backup_options_page_title": "Tuỳ chỉnh sao lưu", - "backup_setting_subtitle": "Manage background and foreground upload settings", + "backup_setting_subtitle": "Quản lý cách tải lên khi app đang được sử dụng, và khi app được đưa vào nền", "cache_settings_album_thumbnails": "Trang thư viện hình thu nhỏ ({} ảnh)", "cache_settings_clear_cache_button": "Xoá bộ nhớ đệm", "cache_settings_clear_cache_button_title": "Xóa bộ nhớ đệm của ứng dụng. Điều này sẽ ảnh hưởng đến hiệu suất của ứng dụng đến khi bộ nhớ đệm được tạo lại.", @@ -156,17 +156,17 @@ "cache_settings_tile_subtitle": "Kiểm soát cách xử lý lưu trữ cục bộ", "cache_settings_tile_title": "Lưu trữ cục bộ", "cache_settings_title": "Cài đặt bộ nhớ đệm", - "cancel": "Cancel", - "canceled": "Canceled", - "change_display_order": "Change display order", + "cancel": "Huỷ", + "canceled": "Huỷ bỏ", + "change_display_order": "Thay đổi thứ tự hiểu thị", "change_password_form_confirm_password": "Xác nhận mật khẩu", "change_password_form_description": "Xin chào {name},\n\nĐây là lần đầu tiên bạn đăng nhập vào hệ thống hoặc đã có yêu cầu thay đổi mật khẩu. Vui lòng nhập mật khẩu mới bên dưới.", "change_password_form_new_password": "Mật khẩu mới", "change_password_form_password_mismatch": "Mật khẩu không giống nhau", "change_password_form_reenter_new_password": "Nhập lại mật khẩu mới", - "check_corrupt_asset_backup": "Check for corrupt asset backups", - "check_corrupt_asset_backup_button": "Perform check", - "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", + "check_corrupt_asset_backup": "Kiểm tra tập tin bị hỏng", + "check_corrupt_asset_backup_button": "Tiến hành kiểm tra", + "check_corrupt_asset_backup_description": "Chạy lệnh này qua mạng WiFi và khi toàn bộ tập tin đã được tải lên trên máy chủ. Lệnh này có thể chạy trong một vài phút", "client_cert_dialog_msg_confirm": "Đồng ý", "client_cert_enter_password": "Nhập mật khẩu", "client_cert_import": "Nhập", @@ -174,14 +174,14 @@ "client_cert_invalid_msg": "Tập tin chứng chỉ không hợp lệ hoặc sai mật khẩu", "client_cert_remove": "Xoá", "client_cert_remove_msg": "Chứng chỉ khách đã bị xoá", - "client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate Import/Remove is available only before login", + "client_cert_subtitle": "Chỉ hỗ trợ định dạng PKCS12 (.p12, .pfx). Chỉ có thể nhập/xóa chứng chỉ trước khi đăng nhập", "client_cert_title": "Chứng chỉ khách SSL", "common_add_to_album": "Thêm vào album", "common_change_password": "Thay đổi mật khẩu", "common_create_new_album": "Tạo album mới", "common_server_error": "Vui lòng kiểm tra kết nối mạng của bạn, đảm bảo máy chủ có thể truy cập được và các phiên bản ứng dụng/máy chủ phải tương thích với nhau", "common_shared": "Chia sẻ", - "completed": "Completed", + "completed": "Hoàn tất", "contextual_search": "Bình mình trên bãi biển", "control_bottom_app_bar_add_to_album": "Thêm vào album", "control_bottom_app_bar_album_info": "{} mục", @@ -203,9 +203,9 @@ "control_bottom_app_bar_unarchive": "Huỷ lưu trữ", "control_bottom_app_bar_unfavorite": "Bỏ yêu thích", "control_bottom_app_bar_upload": "Tải lên", - "create_album": "Create album", + "create_album": "Tạo album", "create_album_page_untitled": "Không tiêu đề", - "create_new": "CREATE NEW", + "create_new": "TẠO MỚI", "create_shared_album_page_create": "Tạo", "create_shared_album_page_share": "Chia sẻ", "create_shared_album_page_share_add_assets": "THÊM ẢNH", @@ -213,7 +213,7 @@ "crop": "Cắt", "curated_location_page_title": "Địa điểm", "curated_object_page_title": "Sự vật", - "current_server_address": "Current server address", + "current_server_address": "Địa chủ máy chủ hiện tại", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "Xoá liên kết đã chia sẻ", "description_input_hint_text": "Thêm mô tả...", "description_input_submit_error": "Cập nhật mô tả không thành công, vui lòng kiểm tra nhật ký để biết thêm chi tiết", + "description_search": "Ngày khám phá Sapa", "download_canceled": "Đã hủy tải xuống", "download_complete": "Tải xuống hoàn tất", "download_enqueue": "Đang chờ tải xuống", @@ -247,13 +248,14 @@ "download_sucess_android": "Phương tiện đã được tải vào DCIM/Immich", "download_waiting_to_retry": "Đang chờ thử lại", "edit_date_time_dialog_date_time": "Ngày và Giờ", + "edit_date_time_dialog_search_timezone": "Tìm múi giờ", "edit_date_time_dialog_timezone": "Múi giờ", "edit_image_title": "Sửa", "edit_location_dialog_title": "Vị trí", - "end_date": "End date", - "enqueued": "Enqueued", - "enter_wifi_name": "Enter WiFi name", - "error_change_sort_album": "Failed to change album sort order", + "end_date": "Ngày kết thúc", + "enqueued": "Đã xếp hàng", + "enter_wifi_name": "Nhập tên WiFi", + "error_change_sort_album": "Thay đổi thứ tự hiểu thị thất bại", "error_saving_image": "Lỗi: {}", "exif_bottom_sheet_description": "Thêm mô tả...", "exif_bottom_sheet_details": "CHI TIẾT", @@ -265,16 +267,16 @@ "experimental_settings_new_asset_list_title": "Bật lưới ảnh thử nghiệm", "experimental_settings_subtitle": "Sử dụng có thể rủi ro!", "experimental_settings_title": "Chưa hoàn thiện", - "external_network": "External network", - "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", - "failed": "Failed", - "favorites": "Favorites", + "external_network": "Mạng bên ngoài", + "external_network_sheet_info": "Khi không ở trên mạng WiFi ưa thích, ứng dụng sẽ kết nối với máy chủ thông qua URL đầu tiên bên dưới mà nó có thể truy cập, bắt đầu từ trên xuống dưới", + "failed": "Thất bại", + "favorites": "Yêu thích", "favorites_page_no_favorites": "Không tìm thấy ảnh yêu thích", "favorites_page_title": "Ảnh yêu thích", "filename_search": "Tên hoặc phần mở rộng tập tin", "filter": "Bộ lọc", - "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", - "grant_permission": "Grant permission", + "get_wifiname_error": "Không thể lấy tên Wi-Fi. Hãy đảm bảo bạn đã cấp các quyền cần thiết và được kết nối với mạng Wi-Fi", + "grant_permission": "Cấp quyền", "haptic_feedback_switch": "Bật phản hồi haptic", "haptic_feedback_title": "Phản hồi Hapic", "header_settings_add_header_tip": "Thêm Header", @@ -307,7 +309,7 @@ "image_viewer_page_state_provider_share_error": "Chia sẻ không thành công", "invalid_date": "Ngày không hợp lệ", "invalid_date_format": "Định dạng ngày không hợp lệ", - "library": "Library", + "library": "Thư viện", "library_page_albums": "Album", "library_page_archive": "Kho lưu trữ", "library_page_device_albums": "Album trên thiết bị", @@ -320,10 +322,10 @@ "library_page_sort_most_oldest_photo": "Ảnh cũ nhất", "library_page_sort_most_recent_photo": "Ảnh gần đây nhất", "library_page_sort_title": "Tiêu đề album", - "local_network": "Local network", - "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", - "location_permission": "Location permission", - "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", + "local_network": "Mạng nội bộ", + "local_network_sheet_info": "Ứng dụng sẽ kết nối với máy chủ qua URL này khi sử dụng mạng Wi-Fi được chỉ định", + "location_permission": "Quyền truy cập vị trí", + "location_permission_content": "Để sử dụng tính năng tự động chuyển đổi, Immich cần có quyền vị trí chính xác để có thể đọc tên mạng WiFi hiện tại", "location_picker_choose_on_map": "Chọn trên bản đồ", "location_picker_latitude": "Vĩ độ", "location_picker_latitude_error": "Nhập vĩ độ hợp lệ", @@ -336,7 +338,7 @@ "login_form_back_button_text": "Quay lại", "login_form_button_text": "Đăng nhập", "login_form_email_hint": "emailcuaban@email.com", - "login_form_endpoint_hint": "http://địa-chỉ-ip-máy-chủ-bạn:cổng/api", + "login_form_endpoint_hint": "http://địa-chỉ-ip-máy-chủ-bạn:cổng", "login_form_endpoint_url": "Địa chỉ máy chủ", "login_form_err_http": "Vui lòng xác định http:// hoặc https://", "login_form_err_invalid_email": "Email không hợp lệ", @@ -392,9 +394,9 @@ "motion_photos_page_title": "Ảnh động", "multiselect_grid_edit_date_time_err_read_only": "Không thể chỉnh sửa ngày của ảnh chỉ có quyền đọc, bỏ qua", "multiselect_grid_edit_gps_err_read_only": "Không thể chỉnh sửa vị trí của ảnh chỉ có quyền đọc, bỏ qua", - "my_albums": "My albums", - "networking_settings": "Networking", - "networking_subtitle": "Manage the server endpoint settings", + "my_albums": "Album của tôi", + "networking_settings": "Mạng", + "networking_subtitle": "Quản lý các địa chỉ máy chủ", "no_assets_to_show": "Không có mục nào để hiển thị", "no_name": "Không có tên", "notification_permission_dialog_cancel": "Từ chối", @@ -403,8 +405,8 @@ "notification_permission_list_tile_content": "Cấp quyền để bật thông báo", "notification_permission_list_tile_enable_button": "Bật thông báo", "notification_permission_list_tile_title": "Quyền thông báo", - "not_selected": "Not selected", - "on_this_device": "On this device", + "not_selected": "Không được chọn", + "on_this_device": "Trên máy này", "partner_list_user_photos": "Ảnh của {user}", "partner_list_view_all": "Xem tất cả", "partner_page_add_partner": "Thêm người thân", @@ -416,9 +418,9 @@ "partner_page_stop_sharing_content": "{} sẽ không thể truy cập ảnh của bạn.", "partner_page_stop_sharing_title": "Ngừng chia sẻ ảnh của bạn?", "partner_page_title": "Người thân", - "partners": "Partners", - "paused": "Paused", - "people": "People", + "partners": "Người thân", + "paused": "Ngừng", + "people": "Con ngươì", "permission_onboarding_back": "Quay lại", "permission_onboarding_continue_anyway": "Vẫn tiếp tục", "permission_onboarding_get_started": "Bắt đầu", @@ -429,8 +431,8 @@ "permission_onboarding_permission_granted": "Cấp quyền hoàn tất!", "permission_onboarding_permission_limited": "Quyền truy cập vào ảnh của bạn bị hạn chế. Để Immich sao lưu và quản lý toàn bộ thư viện ảnh của bạn, hãy cấp quyền truy cập toàn bộ ảnh trong Cài đặt.", "permission_onboarding_request": "Immich cần quyền để xem ảnh và video của bạn", - "places": "Places", - "preferences_settings_subtitle": "Manage the app's preferences", + "places": "Địa danh", + "preferences_settings_subtitle": "Quản lý tuỳ chọn của app", "preferences_settings_title": "Tuỳ chỉnh", "profile_drawer_app_logs": "Nhật ký", "profile_drawer_client_out_of_date_major": "Ứng dụng đã lỗi thời. Vui lòng cập nhật lên phiên bản chính mới nhất.", @@ -443,26 +445,29 @@ "profile_drawer_settings": "Cài đặt", "profile_drawer_sign_out": "Đăng xuất", "profile_drawer_trash": "Thùng rác", - "recently_added": "Recently added", + "recently_added": "Thêm gần đây", "recently_added_page_title": "Mới thêm gần đây", - "save": "Save", + "save": "Lưu", "save_to_gallery": "Lưu vào thư viện", "scaffold_body_error_occurred": "Xảy ra lỗi", - "search_albums": "Search albums", + "search_albums": "Tìm kiếm album", "search_bar_hint": "Tìm kiếm ảnh của bạn", "search_filter_apply": "Áp dụng bộ lọc", "search_filter_camera": "Máy ảnh", "search_filter_camera_make": "Thương hiệu", "search_filter_camera_model": "Dòng máy ảnh", "search_filter_camera_title": "Chọn loại máy ảnh", + "search_filter_contextual": "Tìm kiếm theo ngữ cảnh", "search_filter_date": "Ngày", "search_filter_date_interval": "{start} đến {end}", "search_filter_date_title": "Chọn khoảng ngày", + "search_filter_description": "Tìm kiếm theo miêu tả", "search_filter_display_option_archive": "Kho lưu trữ", "search_filter_display_option_favorite": "Yêu thích", "search_filter_display_option_not_in_album": "Không nằm trong album", "search_filter_display_options": "Tuỳ chọn hiển thị", "search_filter_display_options_title": "Tuỳ chọn hiển thị", + "search_filter_filename": "Tìm kiếm theo tên tập tin", "search_filter_location": "Vị trí", "search_filter_location_city": "Thành phố", "search_filter_location_country": "Quốc gia", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "Chọn loại phương tiện", "search_filter_media_type_video": "Video", "search_filter_people": "Mọi người", + "search_filter_people_hint": "Tìm kiếm theo gương mặt", "search_filter_people_title": "Chọn người", + "search_no_more_result": "Không còn kết quả", + "search_no_result": "Không có kết quả tìm kiếm, xin thử một cụm từ khác hoặc kết hợp các filter khác", "search_page_categories": "Danh mục", "search_page_favorites": "Ảnh yêu thích", "search_page_motion_photos": "Ảnh động", @@ -491,7 +499,7 @@ "search_page_places": "Địa điểm", "search_page_recently_added": "Mới thêm gần đây", "search_page_screenshots": "Ảnh màn hình", - "search_page_search_photos_videos": "Search for your photos and videos", + "search_page_search_photos_videos": "Tìm hình ảnh và video của bạn", "search_page_selfies": "Ảnh selfie", "search_page_things": "Sự vật", "search_page_videos": "Video", @@ -504,7 +512,7 @@ "select_additional_user_for_sharing_page_suggestions": "Gợi ý", "select_user_for_sharing_page_err_album": "Tạo album thất bại", "select_user_for_sharing_page_share_suggestions": "Gợi ý", - "server_endpoint": "Server Endpoint", + "server_endpoint": "Địa chỉ máy chủ", "server_info_box_app_version": "Phiên bản ứng dụng", "server_info_box_latest_release": "Phiên bản mới nhất", "server_info_box_server_url": "Địa chỉ máy chủ", @@ -516,7 +524,7 @@ "setting_image_viewer_preview_title": "Tải ảnh xem trước", "setting_image_viewer_title": "Hình ảnh", "setting_languages_apply": "Áp dụng", - "setting_languages_subtitle": "Change the app's language", + "setting_languages_subtitle": "Quản lý tuỳ chọn ngôn ngữ", "setting_languages_title": "Ngôn ngữ", "setting_notifications_notify_failures_grace_period": "Thông báo sao lưu nền thất bại: {}", "setting_notifications_notify_hours": "{} giờ", @@ -534,8 +542,8 @@ "settings_require_restart": "Vui lòng khởi động lại Immich để áp dụng cài đặt này", "setting_video_viewer_looping_subtitle": "Bật chế độ lặp lại tự động cho video trong chế độ xem chi tiết.", "setting_video_viewer_looping_title": "Lặp lại", - "setting_video_viewer_original_video_subtitle": "When streaming a video from the server, play the original even when a transcode is available. May lead to buffering. Videos available locally are played in original quality regardless of this setting.", - "setting_video_viewer_original_video_title": "Force original video", + "setting_video_viewer_original_video_subtitle": "Khi phát trực tuyến video từ máy chủ, hãy phát video gốc ngay cả khi có bản chuyển mã. Có thể dẫn đến tình trạng chờ. Video có sẵn trên máy được phát ở chất lượng gốc bất kể cài đặt này.", + "setting_video_viewer_original_video_title": "Dùng video gốc", "setting_video_viewer_title": "Video", "share_add": "Thêm", "share_add_photos": "Thêm ảnh", @@ -554,7 +562,7 @@ "shared_album_section_people_owner_label": "Chủ sở hữu", "shared_album_section_people_title": "MỌI NGƯỜI", "share_dialog_preparing": "Đang xử lý...", - "shared_intent_upload_button_progress_text": "{} / {} Uploaded", + "shared_intent_upload_button_progress_text": "{} / {} Đã tải lên", "shared_link_app_bar_title": "Liên kết chia sẻ", "shared_link_clipboard_copied_massage": "Đã sao chép tới bản ghi tạm", "shared_link_clipboard_text": "Liên kết: {}\nMật khẩu: {}", @@ -600,9 +608,9 @@ "shared_link_info_chip_upload": "Tải lên", "shared_link_manage_links": "Quản lý liên kết được chia sẻ", "shared_link_public_album": "Album công khai", - "shared_links": "Shared links", + "shared_links": "Đường dẫn chia sẽ", "share_done": "Hoàn tất", - "shared_with_me": "Shared with me", + "shared_with_me": "Chia sẽ với tôi", "share_invite": "Mời vào album", "sharing_page_album": "Album chia sẻ", "sharing_page_description": "Tạo album chia sẻ để chia sẻ ảnh và video với những người trong mạng của bạn.", @@ -610,7 +618,7 @@ "sharing_silver_appbar_create_shared_album": "Tạo album chia sẻ", "sharing_silver_appbar_shared_links": "Các liên kết chia sẻ", "sharing_silver_appbar_share_partner": "Chia sẻ với người thân", - "start_date": "Start date", + "start_date": "Ngày bắt đầu", "sync": "Đồng bộ", "sync_albums": "Đồng bộ album", "sync_albums_manual_subtitle": "Đồng bộ hóa tất cả video và ảnh đã tải lên vào album sao lưu đã chọn", @@ -635,7 +643,7 @@ "theme_setting_three_stage_loading_subtitle": "Tải ba giai doạn có thể tăng hiệu năng tải ảnh nhưng sẽ tốn dữ liệu mạng đáng kể.", "theme_setting_three_stage_loading_title": "Bật tải ba giai đoạn", "translated_text_options": "Tuỳ chỉnh", - "trash": "Trash", + "trash": "Thùng rác", "trash_emptied": "Đã dọn sạch thùng rác", "trash_page_delete": "Xoá", "trash_page_delete_all": "Xoá tất cả", @@ -649,25 +657,25 @@ "trash_page_select_assets_btn": "Chọn ảnh", "trash_page_select_btn": "Chọn", "trash_page_title": "Thùng rác ({})", - "upload": "Upload", + "upload": "Tải lên", "upload_dialog_cancel": "Từ chối", "upload_dialog_info": "Bạn có muốn sao lưu những mục đã chọn tới máy chủ không?", "upload_dialog_ok": "Tải lên", "upload_dialog_title": "Tải lên ảnh", - "uploading": "Uploading", - "upload_to_immich": "Upload to Immich ({})", - "use_current_connection": "use current connection", - "validate_endpoint_error": "Please enter a valid URL", + "uploading": "Đang tải lên", + "upload_to_immich": "Tải lên Immich", + "use_current_connection": "dùng kết nối hiện tại", + "validate_endpoint_error": "Vui lòng nhập URL hợp lệ", "version_announcement_overlay_ack": "Công nhận", "version_announcement_overlay_release_notes": "ghi chú phát hành", "version_announcement_overlay_text_1": "Chào bạn, có một bản phát hành mới của", "version_announcement_overlay_text_2": "vui lòng dành thời gian của bạn để đến thăm", "version_announcement_overlay_text_3": "và đảm bảo cài đặt docker-compose và tệp .env của bạn đã cập nhật để tránh bất kỳ cấu hình sai sót, đặc biệt nếu bạn dùng WatchTower hoặc bất kỳ cơ chế nào xử lý việc cập nhật ứng dụng máy chủ của bạn tự động.", "version_announcement_overlay_title": "Phiên bản máy chủ có bản cập nhật mới", - "videos": "Videos", + "videos": "Video", "viewer_remove_from_stack": "Xoá khỏi nhóm", "viewer_stack_use_as_main_asset": "Đặt làm lựa chọn hàng đầu", "viewer_unstack": "Huỷ xếp nhóm", - "wifi_name": "WiFi Name", - "your_wifi_name": "Your WiFi name" + "wifi_name": "Tên WiFi", + "your_wifi_name": "Tên WiFi của bạn" } \ No newline at end of file diff --git a/mobile/assets/i18n/zh-CN.json b/mobile/assets/i18n/zh-CN.json index 5e6f470e8a..03244f3c31 100644 --- a/mobile/assets/i18n/zh-CN.json +++ b/mobile/assets/i18n/zh-CN.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "删除共享链接", "description_input_hint_text": "添加描述...", "description_input_submit_error": "更新描述时出错,请检查日志以获取更多详细信息", + "description_search": "在沙巴徒步的日子", "download_canceled": "下载已取消", "download_complete": "下载完成", "download_enqueue": "已加入下载队列", @@ -247,6 +248,7 @@ "download_sucess_android": "媒体已下载至 DCIM/Immich", "download_waiting_to_retry": "等待重试", "edit_date_time_dialog_date_time": "日期和时间", + "edit_date_time_dialog_search_timezone": "搜索时区...", "edit_date_time_dialog_timezone": "时区", "edit_image_title": "编辑", "edit_location_dialog_title": "位置", @@ -336,7 +338,7 @@ "login_form_back_button_text": "后退", "login_form_button_text": "登录", "login_form_email_hint": "youremail@email.com", - "login_form_endpoint_hint": "http(s)://您的服务器地址:端口/api", + "login_form_endpoint_hint": "http://您的服务器地址:端口", "login_form_endpoint_url": "服务器链接地址", "login_form_err_http": "请注明 http:// 或 https://", "login_form_err_invalid_email": "无效的电子邮箱", @@ -455,14 +457,17 @@ "search_filter_camera_make": "制造商", "search_filter_camera_model": "型号", "search_filter_camera_title": "选择相机类型", + "search_filter_contextual": "通过上下文搜索", "search_filter_date": "日期", "search_filter_date_interval": "从{start}到{end}", "search_filter_date_title": "选择日期范围", + "search_filter_description": "通过描述搜索", "search_filter_display_option_archive": "归档", "search_filter_display_option_favorite": "收藏", "search_filter_display_option_not_in_album": "不在相册中", "search_filter_display_options": "显示选项", "search_filter_display_options_title": "显示选项", + "search_filter_filename": "通过文件名搜索", "search_filter_location": "位置", "search_filter_location_city": "城市", "search_filter_location_country": "国家", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "选择媒体类型", "search_filter_media_type_video": "视频", "search_filter_people": "人物", + "search_filter_people_hint": "筛选人物", "search_filter_people_title": "选择人物", + "search_no_more_result": "无更多结果", + "search_no_result": "未找到结果,请尝试不同的搜索词或搜索组合", "search_page_categories": "类别", "search_page_favorites": "收藏", "search_page_motion_photos": "动图", diff --git a/mobile/assets/i18n/zh-Hans.json b/mobile/assets/i18n/zh-Hans.json index d00f5ed9b1..297d768508 100644 --- a/mobile/assets/i18n/zh-Hans.json +++ b/mobile/assets/i18n/zh-Hans.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "删除共享链接", "description_input_hint_text": "添加描述...", "description_input_submit_error": "更新描述时出错,请检查日志以获取更多详细信息", + "description_search": "在沙巴徒步的日子", "download_canceled": "下载已取消", "download_complete": "下载完成", "download_enqueue": "已加入下载队列", @@ -247,6 +248,7 @@ "download_sucess_android": "媒体已下载至 DCIM/Immich", "download_waiting_to_retry": "等待重试", "edit_date_time_dialog_date_time": "日期和时间", + "edit_date_time_dialog_search_timezone": "搜索时区...", "edit_date_time_dialog_timezone": "时区", "edit_image_title": "编辑", "edit_location_dialog_title": "位置", @@ -336,7 +338,7 @@ "login_form_back_button_text": "后退", "login_form_button_text": "登录", "login_form_email_hint": "youremail@email.com", - "login_form_endpoint_hint": "http(s)://您的服务器地址:端口/api", + "login_form_endpoint_hint": "http://您的服务器地址:端口", "login_form_endpoint_url": "服务器链接地址", "login_form_err_http": "请注明 http:// 或 https://", "login_form_err_invalid_email": "无效的电子邮箱", @@ -455,14 +457,17 @@ "search_filter_camera_make": "制造商", "search_filter_camera_model": "型号", "search_filter_camera_title": "选择相机类型", + "search_filter_contextual": "通过上下文搜索", "search_filter_date": "日期", "search_filter_date_interval": "从{start}到{end}", "search_filter_date_title": "选择日期范围", + "search_filter_description": "通过描述搜索", "search_filter_display_option_archive": "归档", "search_filter_display_option_favorite": "收藏", "search_filter_display_option_not_in_album": "不在相册中", "search_filter_display_options": "显示选项", "search_filter_display_options_title": "显示选项", + "search_filter_filename": "通过文件名搜索", "search_filter_location": "位置", "search_filter_location_city": "城市", "search_filter_location_country": "国家", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "选择媒体类型", "search_filter_media_type_video": "视频", "search_filter_people": "人物", + "search_filter_people_hint": "筛选人物", "search_filter_people_title": "选择人物", + "search_no_more_result": "无更多结果", + "search_no_result": "未找到结果,请尝试不同的搜索词或搜索组合", "search_page_categories": "类别", "search_page_favorites": "收藏", "search_page_motion_photos": "动图", diff --git a/mobile/assets/i18n/zh-TW.json b/mobile/assets/i18n/zh-TW.json index 03b2df3e4e..c04342cf03 100644 --- a/mobile/assets/i18n/zh-TW.json +++ b/mobile/assets/i18n/zh-TW.json @@ -231,6 +231,7 @@ "delete_shared_link_dialog_title": "刪除共享鏈接", "description_input_hint_text": "新增描述...", "description_input_submit_error": "更新描述時出錯,請檢查日誌以獲取更多詳細資訊", + "description_search": "Hiking day in Sapa", "download_canceled": "下載已取消", "download_complete": "下載完成", "download_enqueue": "已加入下載隊列", @@ -247,6 +248,7 @@ "download_sucess_android": "媒體已下載至 DCIM/Immich", "download_waiting_to_retry": "等待重試", "edit_date_time_dialog_date_time": "日期和時間", + "edit_date_time_dialog_search_timezone": "Search timezone...", "edit_date_time_dialog_timezone": "時區", "edit_image_title": "編輯", "edit_location_dialog_title": "位置", @@ -336,7 +338,7 @@ "login_form_back_button_text": "後退", "login_form_button_text": "登入", "login_form_email_hint": "youremail@email.com", - "login_form_endpoint_hint": "http(s)://您的伺服器地址:端口/api", + "login_form_endpoint_hint": "http://您的伺服器地址:端口", "login_form_endpoint_url": "伺服器鏈接地址", "login_form_err_http": "請注明 http:// 或 https://", "login_form_err_invalid_email": "電郵無效", @@ -455,14 +457,17 @@ "search_filter_camera_make": "製造商", "search_filter_camera_model": "型號", "search_filter_camera_title": "選擇相機類型", + "search_filter_contextual": "Search by context", "search_filter_date": "日期", "search_filter_date_interval": "從 {start} 到 {end}", "search_filter_date_title": "選擇日期範圍", + "search_filter_description": "Search by description", "search_filter_display_option_archive": "歸檔", "search_filter_display_option_favorite": "收藏", "search_filter_display_option_not_in_album": "不在相簿中", "search_filter_display_options": "顯示選項", "search_filter_display_options_title": "顯示選項", + "search_filter_filename": "Search by file name", "search_filter_location": "位置", "search_filter_location_city": "城市", "search_filter_location_country": "國家", @@ -474,7 +479,10 @@ "search_filter_media_type_title": "選擇媒體類型", "search_filter_media_type_video": "短片", "search_filter_people": "人物", + "search_filter_people_hint": "Filter people", "search_filter_people_title": "選擇人物", + "search_no_more_result": "No more results", + "search_no_result": "No results found, try a different search term or combination", "search_page_categories": "類別", "search_page_favorites": "收藏", "search_page_motion_photos": "動態照片\n", diff --git a/mobile/immich_lint/lib/immich_mobile_immich_lint.dart b/mobile/immich_lint/lib/immich_mobile_immich_lint.dart index 65f3fc18f3..e2622fadd1 100644 --- a/mobile/immich_lint/lib/immich_mobile_immich_lint.dart +++ b/mobile/immich_lint/lib/immich_mobile_immich_lint.dart @@ -1,5 +1,5 @@ -import 'package:analyzer/error/listener.dart'; import 'package:analyzer/error/error.dart' show ErrorSeverity; +import 'package:analyzer/error/listener.dart'; import 'package:custom_lint_builder/custom_lint_builder.dart'; // ignore: depend_on_referenced_packages import 'package:glob/glob.dart'; @@ -65,12 +65,15 @@ class ImportRule extends DartLintRule { ) { if (_rootOffset == -1) { const project = "/immich/mobile/"; - _rootOffset = resolver.path.indexOf(project) + project.length; + _rootOffset = + resolver.path.toLowerCase().indexOf(project) + project.length; } final path = resolver.path.substring(_rootOffset); if ((_allowed != null && _allowed!.matches(path)) && - (_forbidden == null || !_forbidden!.matches(path))) return; + (_forbidden == null || !_forbidden!.matches(path))) { + return; + } context.registry.addImportDirective((node) { final uri = node.uri.stringValue; diff --git a/mobile/immich_lint/pubspec.lock b/mobile/immich_lint/pubspec.lock index e81bad7da2..6d4630f1fb 100644 --- a/mobile/immich_lint/pubspec.lock +++ b/mobile/immich_lint/pubspec.lock @@ -5,23 +5,23 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "45cfa8471b89fb6643fe9bf51bd7931a76b8f5ec2d65de4fb176dba8d4f22c77" + sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" url: "https://pub.dev" source: hosted - version: "73.0.0" + version: "76.0.0" _macros: dependency: transitive description: dart source: sdk - version: "0.3.2" + version: "0.3.3" analyzer: dependency: "direct main" description: name: analyzer - sha256: "4959fec185fe70cce007c57e9ab6983101dbe593d2bf8bbfb4453aaec0cf470a" + sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" url: "https://pub.dev" source: hosted - version: "6.8.0" + version: "6.11.0" analyzer_plugin: dependency: "direct main" description: @@ -34,26 +34,26 @@ packages: dependency: transitive description: name: args - sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.6.0" async: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.13.0" boolean_selector: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" checked_yaml: dependency: transitive description: @@ -74,82 +74,82 @@ packages: dependency: transitive description: name: cli_util - sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19 + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c url: "https://pub.dev" source: hosted - version: "0.4.1" + version: "0.4.2" collection: dependency: transitive description: name: collection - sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.19.1" convert: dependency: transitive description: name: convert - sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.2" crypto: dependency: transitive description: name: crypto - sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27 + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.6" custom_lint: dependency: transitive description: name: custom_lint - sha256: "6e1ec47427ca968f22bce734d00028ae7084361999b41673291138945c5baca0" + sha256: "4500e88854e7581ee43586abeaf4443cb22375d6d289241a87b1aadf678d5545" url: "https://pub.dev" source: hosted - version: "0.6.7" + version: "0.6.10" custom_lint_builder: dependency: "direct main" description: name: custom_lint_builder - sha256: ba2f90fff4eff71d202d097eb14b14f87087eaaef742e956208c0eb9d3a40a21 + sha256: "5a95eff100da256fbf086b329c17c8b49058c261cdf56d3a4157d3c31c511d78" url: "https://pub.dev" source: hosted - version: "0.6.7" + version: "0.6.10" custom_lint_core: dependency: transitive description: name: custom_lint_core - sha256: "4ddbbdaa774265de44c97054dcec058a83d9081d071785ece601e348c18c267d" + sha256: "76a4046cc71d976222a078a8fd4a65e198b70545a8d690a75196dd14f08510f6" url: "https://pub.dev" source: hosted - version: "0.6.5" + version: "0.6.10" dart_style: dependency: transitive description: name: dart_style - sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab" + sha256: "7306ab8a2359a48d22310ad823521d723acfed60ee1f7e37388e8986853b6820" url: "https://pub.dev" source: hosted - version: "2.3.7" + version: "2.3.8" file: dependency: transitive description: name: file - sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.0.1" fixnum: dependency: transitive description: name: fixnum - sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" freezed_annotation: dependency: transitive description: @@ -162,18 +162,18 @@ packages: dependency: "direct main" description: name: glob - sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" hotreloader: dependency: transitive description: name: hotreloader - sha256: ed56fdc1f3a8ac924e717257621d09e9ec20e308ab6352a73a50a1d7a4d9158e + sha256: bc167a1163807b03bada490bfe2df25b0d744df359227880220a5cbd04e5734b url: "https://pub.dev" source: hosted - version: "4.2.0" + version: "4.3.0" json_annotation: dependency: transitive description: @@ -186,74 +186,74 @@ packages: dependency: "direct dev" description: name: lints - sha256: "3315600f3fb3b135be672bf4a178c55f274bebe368325ae18462c89ac1e3b413" + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "5.1.1" logging: dependency: transitive description: name: logging - sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" macros: dependency: transitive description: name: macros - sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" + sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" url: "https://pub.dev" source: hosted - version: "0.1.2-main.4" + version: "0.1.3-main.0" matcher: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.17" meta: dependency: transitive description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.16.0" package_config: dependency: transitive description: name: package_config - sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" path: dependency: transitive description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" pub_semver: dependency: transitive description: name: pub_semver - sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8 + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.5.0" rxdart: dependency: transitive description: @@ -266,10 +266,10 @@ packages: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.10.1" sprintf: dependency: transitive description: @@ -282,89 +282,89 @@ packages: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" stream_transform: dependency: transitive description: name: stream_transform - sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" string_scanner: dependency: transitive description: name: string_scanner - sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.1" term_glyph: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" test_api: dependency: transitive description: name: test_api - sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd url: "https://pub.dev" source: hosted - version: "0.7.3" + version: "0.7.4" typed_data: dependency: transitive description: name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.4.0" uuid: dependency: transitive description: name: uuid - sha256: f33d6bb662f0e4f79dcd7ada2e6170f3b3a2530c28fc41f49a411ddedd576a77 + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff url: "https://pub.dev" source: hosted - version: "4.5.0" + version: "4.5.1" vm_service: dependency: transitive description: name: vm_service - sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 url: "https://pub.dev" source: hosted - version: "14.2.5" + version: "15.0.0" watcher: dependency: transitive description: name: watcher - sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" yaml: dependency: transitive description: name: yaml - sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.3" sdks: - dart: ">=3.5.0 <4.0.0" + dart: ">=3.6.0 <4.0.0" diff --git a/mobile/immich_lint/pubspec.yaml b/mobile/immich_lint/pubspec.yaml index 5d871b03e6..4cfd8abe81 100644 --- a/mobile/immich_lint/pubspec.yaml +++ b/mobile/immich_lint/pubspec.yaml @@ -5,7 +5,7 @@ environment: sdk: '>=3.0.0 <4.0.0' dependencies: - analyzer: ^7.0.0 + analyzer: ^6.0.0 analyzer_plugin: ^0.11.3 custom_lint_builder: ^0.6.4 glob: ^2.1.2 diff --git a/mobile/integration_test/test_utils/general_helper.dart b/mobile/integration_test/test_utils/general_helper.dart index bf2b252b2b..8e17bae9d3 100644 --- a/mobile/integration_test/test_utils/general_helper.dart +++ b/mobile/integration_test/test_utils/general_helper.dart @@ -4,12 +4,13 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/main.dart' as app; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:integration_test/integration_test.dart'; -import 'package:isar/isar.dart'; // ignore: depend_on_referenced_packages import 'package:meta/meta.dart'; -import 'package:immich_mobile/main.dart' as app; import 'login_helper.dart'; @@ -38,13 +39,17 @@ class ImmichTestHelper { static Future loadApp(WidgetTester tester) async { await EasyLocalization.ensureInitialized(); // Clear all data from Isar (reuse existing instance if available) - final db = Isar.getInstance() ?? await app.loadDb(); + final db = await Bootstrap.initIsar(); + await Bootstrap.initDomain(db); await Store.clear(); await db.writeTxn(() => db.clear()); // Load main Widget await tester.pumpWidget( ProviderScope( - overrides: [dbProvider.overrideWithValue(db)], + overrides: [ + dbProvider.overrideWithValue(db), + isarProvider.overrideWithValue(db), + ], child: const app.MainWidget(), ), ); diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 00a63be8d7..4f030b0495 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -3,7 +3,6 @@ PODS: - Flutter - connectivity_plus (0.0.1): - Flutter - - FlutterMacOS - device_info_plus (0.0.1): - Flutter - DKImagePickerController/Core (4.3.9): @@ -43,16 +42,15 @@ PODS: - Flutter (1.0.0) - flutter_local_notifications (0.0.1): - Flutter - - flutter_native_splash (0.0.1): + - flutter_native_splash (2.4.3): - Flutter - flutter_udid (0.0.1): - Flutter - SAMKeychain - - flutter_web_auth (0.6.0): + - flutter_web_auth_2 (3.0.0): - Flutter - fluttertoast (0.0.2): - Flutter - - Toast - geolocator_apple (1.2.0): - Flutter - image_picker_ios (0.0.1): @@ -61,10 +59,10 @@ PODS: - Flutter - isar_flutter_libs (1.0.0): - Flutter - - MapLibre (5.14.0-pre3) + - MapLibre (6.5.0) - maplibre_gl (0.0.1): - Flutter - - MapLibre (= 5.14.0-pre3) + - MapLibre (= 6.5.0) - native_video_player (1.0.0): - Flutter - network_info_plus (0.0.1): @@ -74,17 +72,15 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - path_provider_ios (0.0.1): - - Flutter - permission_handler_apple (9.3.0): - Flutter - photo_manager (2.0.0): - Flutter - FlutterMacOS - SAMKeychain (1.5.3) - - SDWebImage (5.20.0): - - SDWebImage/Core (= 5.20.0) - - SDWebImage/Core (5.20.0) + - SDWebImage (5.21.0): + - SDWebImage/Core (= 5.21.0) + - SDWebImage/Core (5.21.0) - share_handler_ios (0.0.14): - Flutter - share_handler_ios/share_handler_ios_models (= 0.0.14) @@ -98,11 +94,10 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - - sqflite (0.0.3): + - sqflite_darwin (0.0.4): - Flutter - FlutterMacOS - SwiftyGif (5.4.5) - - Toast (4.1.1) - url_launcher_ios (0.0.1): - Flutter - wakelock_plus (0.0.1): @@ -110,14 +105,14 @@ PODS: DEPENDENCIES: - background_downloader (from `.symlinks/plugins/background_downloader/ios`) - - connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`) + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`) - - flutter_web_auth (from `.symlinks/plugins/flutter_web_auth/ios`) + - flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`) - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) - geolocator_apple (from `.symlinks/plugins/geolocator_apple/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) @@ -128,14 +123,13 @@ DEPENDENCIES: - network_info_plus (from `.symlinks/plugins/network_info_plus/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - photo_manager (from `.symlinks/plugins/photo_manager/ios`) - share_handler_ios (from `.symlinks/plugins/share_handler_ios/ios`) - share_handler_ios_models (from `.symlinks/plugins/share_handler_ios/ios/Models`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - - sqflite (from `.symlinks/plugins/sqflite/darwin`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) @@ -147,13 +141,12 @@ SPEC REPOS: - SAMKeychain - SDWebImage - SwiftyGif - - Toast EXTERNAL SOURCES: background_downloader: :path: ".symlinks/plugins/background_downloader/ios" connectivity_plus: - :path: ".symlinks/plugins/connectivity_plus/darwin" + :path: ".symlinks/plugins/connectivity_plus/ios" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" file_picker: @@ -166,8 +159,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_native_splash/ios" flutter_udid: :path: ".symlinks/plugins/flutter_udid/ios" - flutter_web_auth: - :path: ".symlinks/plugins/flutter_web_auth/ios" + flutter_web_auth_2: + :path: ".symlinks/plugins/flutter_web_auth_2/ios" fluttertoast: :path: ".symlinks/plugins/fluttertoast/ios" geolocator_apple: @@ -188,8 +181,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" - path_provider_ios: - :path: ".symlinks/plugins/path_provider_ios/ios" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" photo_manager: @@ -202,50 +193,48 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/share_plus/ios" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" - sqflite: - :path: ".symlinks/plugins/sqflite/darwin" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" wakelock_plus: :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: - background_downloader: 9f788ffc5de45acf87d6380e91ca0841066c18cf - connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db + background_downloader: 3ca0e156ad83a9fc1c8300f5f7c38e94e2d0bf51 + connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 - file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 + file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086 - flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778 - flutter_udid: a2482c67a61b9c806ef59dd82ed8d007f1b7ac04 - flutter_web_auth: acc15a8fd7bba796a933c724a6dffc3d00f07c27 - fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c - geolocator_apple: 6cbaf322953988e009e5ecb481f07efece75c450 + flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29 + flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab + flutter_web_auth_2: 06d500582775790a0d4c323222fcb6d7990f9603 + fluttertoast: 21eecd6935e7064cc1fcb733a4c5a428f3f24f0f + geolocator_apple: 9bcea1918ff7f0062d98345d238ae12718acfbc1 image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 isar_flutter_libs: fdf730ca925d05687f36d7f1d355e482529ed097 - MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef - maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9 + MapLibre: 0ebfa9329d313cec8bf0a5ba5a336a1dc903785e + maplibre_gl: be7b98f1c3ed75bf77f321eec04df359d0ff6f62 native_video_player: d12af78a1a4a8cf09775a5177d5b392def6fd23c network_info_plus: 6613d9d7cdeb0e6f366ed4dbe4b3c51c52d567a9 package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 - path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c - SDWebImage: 73c6079366fea25fa4bb9640d5fb58f0893facd8 + SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868 share_handler_ios: 6dd3a4ac5ca0d955274aec712ba0ecdcaf583e7c share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871 share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 - sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 - Toast: 1f5ea13423a1e6674c4abdac5be53587ae481c4e url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe - wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1 + wakelock_plus: 373cfe59b235a6dd5837d0fb88791d2f13a90d56 PODFILE CHECKSUM: 03b7eead4ee77b9e778179eeb0f3b5513617451c diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 10133cc330..1d77f6cc4a 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -541,7 +541,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 187; + CURRENT_PROJECT_VERSION = 197; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -685,7 +685,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 187; + CURRENT_PROJECT_VERSION = 197; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -715,7 +715,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 187; + CURRENT_PROJECT_VERSION = 197; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -748,7 +748,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 197; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -791,7 +791,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 197; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -831,7 +831,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 197; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 8f635bc61b..fd62618205 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -1,7 +1,7 @@ import BackgroundTasks import Flutter import network_info_plus -import path_provider_ios +import path_provider_foundation import permission_handler_apple import photo_manager import shared_preferences_foundation @@ -18,21 +18,14 @@ import UIKit UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate } - do { - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) - try AVAudioSession.sharedInstance().setActive(true) - } catch { - print("Failed to set audio session category. Error: \(error)") - } - GeneratedPluginRegistrant.register(with: self) BackgroundServicePlugin.registerBackgroundProcessing() BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!) BackgroundServicePlugin.setPluginRegistrantCallback { registry in - if !registry.hasPlugin("org.cocoapods.path-provider-ios") { - FLTPathProviderPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.path-provider-ios")!) + if !registry.hasPlugin("org.cocoapods.path-provider-foundation") { + PathProviderPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.path-provider-foundation")!) } if !registry.hasPlugin("org.cocoapods.photo-manager") { diff --git a/mobile/ios/Runner/BackgroundSync/BackgroundServicePlugin.swift b/mobile/ios/Runner/BackgroundSync/BackgroundServicePlugin.swift index c84b037daf..cac9faab01 100644 --- a/mobile/ios/Runner/BackgroundSync/BackgroundServicePlugin.swift +++ b/mobile/ios/Runner/BackgroundSync/BackgroundServicePlugin.swift @@ -160,7 +160,7 @@ class BackgroundServicePlugin: NSObject, FlutterPlugin { } } - // Called by the flutter code when enabled so that we can turn on the backround services + // Called by the flutter code when enabled so that we can turn on the background services // and save the callback information to communicate on this method channel public func handleBackgroundEnable(call: FlutterMethodCall, result: FlutterResult) { @@ -249,7 +249,7 @@ class BackgroundServicePlugin: NSObject, FlutterPlugin { result(true) } - // Returns the number of currently scheduled background processes to Flutter, striclty + // Returns the number of currently scheduled background processes to Flutter, strictly // for debugging func handleNumberOfProcesses(call: FlutterMethodCall, result: @escaping FlutterResult) { BGTaskScheduler.shared.getPendingTaskRequests { requests in @@ -355,7 +355,7 @@ class BackgroundServicePlugin: NSObject, FlutterPlugin { let isExpensive = wifiMonitor.currentPath.isExpensive if (isExpensive) { // The network is expensive and we have required Wi-Fi - // Therfore, we will simply complete the task without + // Therefore, we will simply complete the task without // running it task.setTaskCompleted(success: true) return diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index be5ec5d9d7..0e97c377cf 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -78,7 +78,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.124.0 + 1.129.0 CFBundleSignature ???? CFBundleURLTypes @@ -93,7 +93,7 @@ CFBundleVersion - 187 + 197 FLTEnableImpeller ITSAppUsesNonExemptEncryption diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index c88974a9e5..f9a99e76ba 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.125.1" + version_number: "1.129.0" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart index cc0e7ca215..868b036d1b 100644 --- a/mobile/lib/constants/constants.dart +++ b/mobile/lib/constants/constants.dart @@ -1,3 +1,6 @@ const int noDbId = -9223372036854775808; // from Isar const double downloadCompleted = -1; const double downloadFailed = -2; + +// Number of log entries to retain on app start +const int kLogTruncateLimit = 250; diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart index a9b5107426..3a3bf9959a 100644 --- a/mobile/lib/constants/enums.dart +++ b/mobile/lib/constants/enums.dart @@ -2,3 +2,9 @@ enum SortOrder { asc, desc, } + +enum TextSearchType { + context, + filename, + description, +} diff --git a/mobile/lib/constants/locales.dart b/mobile/lib/constants/locales.dart index e4e01f5b53..fa2717fcb7 100644 --- a/mobile/lib/constants/locales.dart +++ b/mobile/lib/constants/locales.dart @@ -5,7 +5,7 @@ const Map locales = { 'English (en_US)': Locale('en', 'US'), // Additional locales 'Arabic (ar_JO)': Locale('ar', 'JO'), - 'Catalan (ca_CA)': Locale('ca', 'CA'), + 'Catalan (ca)': Locale('ca'), 'Chinese (zh_CN)': Locale('zh', 'CN'), 'Chinese Simplified (zh_Hans)': Locale('zh', 'Hans'), 'Chinese TW (zh_TW)': Locale('zh', 'TW'), diff --git a/mobile/lib/domain/README.md b/mobile/lib/domain/README.md new file mode 100644 index 0000000000..f9bb2ee561 --- /dev/null +++ b/mobile/lib/domain/README.md @@ -0,0 +1,34 @@ +# Domain Layer + +This directory contains the domain layer of Immich. The domain layer is responsible for the business logic of the app. It includes interfaces for repositories, models, services and utilities. This layer should never depend on anything from the presentation layer or from the infrastructure layer. + +## Structure + +- **[Interfaces](./interfaces/)**: These are the interfaces that define the contract for data operations. +- **[Models](./models/)**: These are the core data classes that represent the business models. +- **[Services](./services/)**: These are the classes that contain the business logic and interact with the repositories. +- **[Utils](./utils/)**: These are utility classes and functions that provide common functionalities used across the domain layer. + +``` +domain/ +├── interfaces/ +│ └── user.interface.dart +├── models/ +│ └── user.model.dart +├── services/ +│ └── user.service.dart +└── utils/ + └── date_utils.dart +``` + +## Usage + +The domain layer provides services that implement the business logic by consuming repositories through dependency injection. Services are exposed through Riverpod providers in the root `providers` directory. + +```dart +// In presentation layer +final userService = ref.watch(userServiceProvider); +final user = await userService.getUser(userId); +``` + +The presentation layer should never directly use repositories, but instead interact with the domain layer through services. \ No newline at end of file diff --git a/mobile/lib/domain/interfaces/db.interface.dart b/mobile/lib/domain/interfaces/db.interface.dart new file mode 100644 index 0000000000..5645d15c47 --- /dev/null +++ b/mobile/lib/domain/interfaces/db.interface.dart @@ -0,0 +1,3 @@ +abstract interface class IDatabaseRepository { + Future transaction(Future Function() callback); +} diff --git a/mobile/lib/domain/interfaces/exif.interface.dart b/mobile/lib/domain/interfaces/exif.interface.dart new file mode 100644 index 0000000000..a5de6167e9 --- /dev/null +++ b/mobile/lib/domain/interfaces/exif.interface.dart @@ -0,0 +1,14 @@ +import 'package:immich_mobile/domain/interfaces/db.interface.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; + +abstract interface class IExifInfoRepository implements IDatabaseRepository { + Future get(int assetId); + + Future update(ExifInfo exifInfo); + + Future> updateAll(List exifInfos); + + Future delete(int assetId); + + Future deleteAll(); +} diff --git a/mobile/lib/domain/interfaces/log.interface.dart b/mobile/lib/domain/interfaces/log.interface.dart new file mode 100644 index 0000000000..27e91c5488 --- /dev/null +++ b/mobile/lib/domain/interfaces/log.interface.dart @@ -0,0 +1,17 @@ +import 'dart:async'; + +import 'package:immich_mobile/domain/interfaces/db.interface.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; + +abstract interface class ILogRepository implements IDatabaseRepository { + Future insert(LogMessage log); + + Future insertAll(Iterable logs); + + Future> getAll(); + + Future deleteAll(); + + /// Truncates the logs to the most recent [limit]. Defaults to recent 250 logs + Future truncate({int limit = 250}); +} diff --git a/mobile/lib/domain/interfaces/store.interface.dart b/mobile/lib/domain/interfaces/store.interface.dart new file mode 100644 index 0000000000..7a45f9dbe0 --- /dev/null +++ b/mobile/lib/domain/interfaces/store.interface.dart @@ -0,0 +1,18 @@ +import 'package:immich_mobile/domain/interfaces/db.interface.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; + +abstract interface class IStoreRepository implements IDatabaseRepository { + Future insert(StoreKey key, T value); + + Future tryGet(StoreKey key); + + Stream watch(StoreKey key); + + Stream watchAll(); + + Future update(StoreKey key, T value); + + Future delete(StoreKey key); + + Future deleteAll(); +} diff --git a/mobile/lib/domain/interfaces/sync_api.interface.dart b/mobile/lib/domain/interfaces/sync_api.interface.dart new file mode 100644 index 0000000000..fb8f1aa46e --- /dev/null +++ b/mobile/lib/domain/interfaces/sync_api.interface.dart @@ -0,0 +1,7 @@ +import 'package:immich_mobile/domain/models/sync/sync_event.model.dart'; + +abstract interface class ISyncApiRepository { + Future ack(String data); + + Stream> watchUserSyncEvent(); +} diff --git a/mobile/lib/domain/models/exif.model.dart b/mobile/lib/domain/models/exif.model.dart new file mode 100644 index 0000000000..e95653ca4e --- /dev/null +++ b/mobile/lib/domain/models/exif.model.dart @@ -0,0 +1,177 @@ +class ExifInfo { + final int? assetId; + final int? fileSize; + final String? description; + final bool isFlipped; + final String? orientation; + final String? timeZone; + final DateTime? dateTimeOriginal; + + // GPS + final double? latitude; + final double? longitude; + final String? city; + final String? state; + final String? country; + + // Camera related + final String? make; + final String? model; + final String? lens; + final double? f; + final double? mm; + final int? iso; + final double? exposureSeconds; + + bool get hasCoordinates => + latitude != null && longitude != null && latitude != 0 && longitude != 0; + + String get exposureTime { + if (exposureSeconds == null) { + return ""; + } + if (exposureSeconds! < 1) { + return "1/${(1.0 / exposureSeconds!).round()} s"; + } + return "${exposureSeconds!.toStringAsFixed(1)} s"; + } + + String get fNumber => f == null ? "" : f!.toStringAsFixed(1); + + String get focalLength => mm == null ? "" : mm!.toStringAsFixed(1); + + const ExifInfo({ + this.assetId, + this.fileSize, + this.description, + this.orientation, + this.timeZone, + this.dateTimeOriginal, + 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.orientation == orientation && + other.timeZone == timeZone && + other.dateTimeOriginal == dateTimeOriginal && + 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 ^ + timeZone.hashCode ^ + dateTimeOriginal.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'}, +timeZone: ${timeZone ?? 'NA'}, +dateTimeOriginal: ${dateTimeOriginal ?? '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, + 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, + 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/log.model.dart b/mobile/lib/domain/models/log.model.dart new file mode 100644 index 0000000000..dffd1cccda --- /dev/null +++ b/mobile/lib/domain/models/log.model.dart @@ -0,0 +1,65 @@ +/// 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 ?? ''}, +}'''; + } +} diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart new file mode 100644 index 0000000000..06b946b3f6 --- /dev/null +++ b/mobile/lib/domain/models/store.model.dart @@ -0,0 +1,99 @@ +import 'package:immich_mobile/entities/user.entity.dart'; + +/// Key for each possible value in the `Store`. +/// Defines the data type for each value +enum StoreKey { + version._(0), + assetETag._(1), + currentUser._(2), + deviceIdHash._(3), + deviceId._(4), + backupFailedSince._(5), + backupRequireWifi._(6), + backupRequireCharging._(7), + backupTriggerDelay._(8), + serverUrl._(10), + accessToken._(11), + serverEndpoint._(12), + autoBackup._(13), + backgroundBackup._(14), + sslClientCertData._(15), + sslClientPasswd._(16), + // user settings from [AppSettingsEnum] below: + loadPreview._(100), + loadOriginal._(101), + themeMode._(102), + tilesPerRow._(103), + dynamicLayout._(104), + groupAssetsBy._(105), + uploadErrorNotificationGracePeriod._(106), + backgroundBackupTotalProgress._(107), + backgroundBackupSingleProgress._(108), + storageIndicator._(109), + thumbnailCacheSize._(110), + imageCacheSize._(111), + albumThumbnailCacheSize._(112), + selectedAlbumSortOrder._(113), + advancedTroubleshooting._(114), + logLevel._(115), + preferRemoteImage._(116), + loopVideo._(117), + // map related settings + mapShowFavoriteOnly._(118), + mapRelativeDate._(119), + selfSignedCert._(120), + mapIncludeArchived._(121), + ignoreIcloudAssets._(122), + selectedAlbumSortReverse._(123), + mapThemeMode._(124), + mapwithPartners._(125), + enableHapticFeedback._(126), + customHeaders._(127), + + // theme settings + primaryColor._(128), + dynamicTheme._(129), + colorfulInterface._(130), + + syncAlbums._(131), + + // Auto endpoint switching + autoEndpointSwitching._(132), + preferredWifiName._(133), + localEndpoint._(134), + externalEndpointList._(135), + + // Video settings + loadOriginalVideo._(136), + ; + + const StoreKey._(this.id); + final int id; + Type get type => T; +} + +class StoreUpdateEvent { + final StoreKey key; + final T? value; + + const StoreUpdateEvent(this.key, this.value); + + @override + String toString() { + return ''' +StoreUpdateEvent: { + key: $key, + value: ${value ?? ''}, +}'''; + } + + @override + bool operator ==(covariant StoreUpdateEvent other) { + if (identical(this, other)) return true; + + return other.key == key && other.value == value; + } + + @override + int get hashCode => key.hashCode ^ value.hashCode; +} diff --git a/mobile/lib/domain/models/sync/sync_event.model.dart b/mobile/lib/domain/models/sync/sync_event.model.dart new file mode 100644 index 0000000000..f4642d59cf --- /dev/null +++ b/mobile/lib/domain/models/sync/sync_event.model.dart @@ -0,0 +1,14 @@ +class SyncEvent { + // dynamic + final dynamic data; + + final String ack; + + SyncEvent({ + required this.data, + required this.ack, + }); + + @override + String toString() => 'SyncEvent(data: $data, ack: $ack)'; +} diff --git a/mobile/lib/domain/services/log.service.dart b/mobile/lib/domain/services/log.service.dart new file mode 100644 index 0000000000..2136912a67 --- /dev/null +++ b/mobile/lib/domain/services/log.service.dart @@ -0,0 +1,159 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/domain/interfaces/log.interface.dart'; +import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:logging/logging.dart'; + +class LogService { + final ILogRepository _logRepository; + final IStoreRepository _storeRepository; + + final List _msgBuffer = []; + + /// Whether to buffer logs in memory before writing to the database. + /// This is useful when logging in quick succession, as it increases performance + /// and reduces NAND wear. However, it may cause the logs to be lost in case of a crash / in isolates. + final bool _shouldBuffer; + Timer? _flushTimer; + + late final StreamSubscription _logSubscription; + + LogService._( + this._logRepository, + this._storeRepository, + this._shouldBuffer, + ) { + // Listen to log messages and write them to the database + _logSubscription = Logger.root.onRecord.listen(_writeLogToDatabase); + } + + static LogService? _instance; + static LogService get I { + if (_instance == null) { + throw const LoggerUnInitializedException(); + } + return _instance!; + } + + static Future init({ + required ILogRepository logRepository, + required IStoreRepository storeRepository, + bool shouldBuffer = true, + }) async { + if (_instance != null) { + return _instance!; + } + _instance = await create( + logRepository: logRepository, + storeRepository: storeRepository, + shouldBuffer: shouldBuffer, + ); + return _instance!; + } + + static Future create({ + required ILogRepository logRepository, + required IStoreRepository storeRepository, + bool shouldBuffer = true, + }) async { + final instance = LogService._(logRepository, storeRepository, shouldBuffer); + // Truncate logs to 250 + await logRepository.truncate(limit: kLogTruncateLimit); + // Get log level from store + final level = await instance._storeRepository.tryGet(StoreKey.logLevel); + if (level != null) { + Logger.root.level = Level.LEVELS.elementAtOrNull(level) ?? Level.INFO; + } + return instance; + } + + Future setlogLevel(LogLevel level) async { + await _storeRepository.insert(StoreKey.logLevel, level.index); + Logger.root.level = level.toLevel(); + } + + Future> getMessages() async { + final logsFromDb = await _logRepository.getAll(); + if (_msgBuffer.isNotEmpty) { + return [..._msgBuffer.reversed, ...logsFromDb]; + } + return logsFromDb; + } + + Future clearLogs() async { + _flushTimer?.cancel(); + _flushTimer = null; + _msgBuffer.clear(); + await _logRepository.deleteAll(); + } + + /// Flush pending log messages to persistent storage + void flush() { + if (_flushTimer == null) { + return; + } + _flushTimer!.cancel(); + // TODO: Rename enable this after moving to sqlite - #16504 + // await _flushBufferToDatabase(); + } + + Future dispose() { + _flushTimer?.cancel(); + _logSubscription.cancel(); + return _flushBufferToDatabase(); + } + + void _writeLogToDatabase(LogRecord r) { + if (kDebugMode) { + debugPrint('[${r.level.name}] [${r.time}] ${r.message}'); + } + + final record = LogMessage( + message: r.message, + level: r.level.toLogLevel(), + createdAt: r.time, + logger: r.loggerName, + error: r.error?.toString(), + stack: r.stackTrace?.toString(), + ); + + if (_shouldBuffer) { + _msgBuffer.add(record); + _flushTimer ??= Timer( + const Duration(seconds: 5), + () => unawaited(_flushBufferToDatabase()), + ); + } else { + unawaited(_logRepository.insert(record)); + } + } + + Future _flushBufferToDatabase() async { + _flushTimer = null; + final buffer = [..._msgBuffer]; + _msgBuffer.clear(); + await _logRepository.insertAll(buffer); + } +} + +class LoggerUnInitializedException implements Exception { + const LoggerUnInitializedException(); + + @override + String toString() => 'Logger is not initialized. Call init()'; +} + +/// Log levels according to dart logging [Level] +extension LevelDomainToInfraExtension on Level { + LogLevel toLogLevel() => + LogLevel.values.elementAtOrNull(Level.LEVELS.indexOf(this)) ?? + LogLevel.info; +} + +extension on LogLevel { + Level toLevel() => Level.LEVELS.elementAtOrNull(index) ?? Level.INFO; +} diff --git a/mobile/lib/domain/services/store.service.dart b/mobile/lib/domain/services/store.service.dart new file mode 100644 index 0000000000..70b9f31c00 --- /dev/null +++ b/mobile/lib/domain/services/store.service.dart @@ -0,0 +1,106 @@ +import 'dart:async'; + +import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; + +class StoreService { + final IStoreRepository _storeRepository; + + final Map _cache = {}; + late final StreamSubscription _storeUpdateSubscription; + + StoreService._({ + required IStoreRepository storeRepository, + }) : _storeRepository = storeRepository; + + // TODO: Temporary typedef to make minimal changes. Remove this and make the presentation layer access store through a provider + static StoreService? _instance; + static StoreService get I { + if (_instance == null) { + throw UnsupportedError("StoreService not initialized. Call init() first"); + } + return _instance!; + } + + // TODO: Replace the implementation with the one from create after removing the typedef + /// Initializes the store with the given [storeRepository] + static Future init({ + required IStoreRepository storeRepository, + }) async { + _instance ??= await create(storeRepository: storeRepository); + return _instance!; + } + + /// Initializes the store with the given [storeRepository] + static Future create({ + required IStoreRepository storeRepository, + }) async { + final instance = StoreService._(storeRepository: storeRepository); + await instance._populateCache(); + instance._storeUpdateSubscription = instance._listenForChange(); + return instance; + } + + /// Fills the cache with the values from the DB + Future _populateCache() async { + for (StoreKey key in StoreKey.values) { + final storeValue = await _storeRepository.tryGet(key); + _cache[key.id] = storeValue; + } + } + + /// Listens for changes in the DB and updates the cache + StreamSubscription _listenForChange() => + _storeRepository.watchAll().listen((event) { + _cache[event.key.id] = event.value; + }); + + /// Disposes the store and cancels the subscription. To reuse the store call init() again + void dispose() async { + await _storeUpdateSubscription.cancel(); + _cache.clear(); + } + + /// Returns the stored value for the given key (possibly null) + T? tryGet(StoreKey key) => _cache[key.id]; + + /// Returns the stored value for the given key or if null the [defaultValue] + /// Throws a [StoreKeyNotFoundException] if both are null + T get(StoreKey key, [T? defaultValue]) { + final value = tryGet(key) ?? defaultValue; + if (value == null) { + throw StoreKeyNotFoundException(key); + } + return value; + } + + /// Asynchronously stores the value in the DB and synchronously in the cache + Future put, T>(U key, T value) async { + if (_cache[key.id] == value) return; + await _storeRepository.insert(key, value); + _cache[key.id] = value; + } + + /// Watches a specific key for changes + Stream watch(StoreKey key) => _storeRepository.watch(key); + + /// Removes the value asynchronously from the DB and synchronously from the cache + Future delete(StoreKey key) async { + await _storeRepository.delete(key); + _cache.remove(key.id); + } + + /// Clears all values from this store (cache and DB) + Future clear() async { + await _storeRepository.deleteAll(); + _cache.clear(); + } +} + +class StoreKeyNotFoundException implements Exception { + final StoreKey key; + const StoreKeyNotFoundException(this.key); + + @override + String toString() => "Key - <${key.name}> not available in Store"; +} diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart new file mode 100644 index 0000000000..72e29b3677 --- /dev/null +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -0,0 +1,49 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/domain/interfaces/sync_api.interface.dart'; +import 'package:openapi/api.dart'; + +class SyncStreamService { + final ISyncApiRepository _syncApiRepository; + + SyncStreamService(this._syncApiRepository); + + StreamSubscription? _userSyncSubscription; + + void syncUsers() { + _userSyncSubscription = + _syncApiRepository.watchUserSyncEvent().listen((events) async { + for (final event in events) { + if (event.data is SyncUserV1) { + final data = event.data as SyncUserV1; + debugPrint("User Update: $data"); + + // final user = await _userRepository.get(data.id); + + // if (user == null) { + // continue; + // } + + // user.name = data.name; + // user.email = data.email; + // user.updatedAt = DateTime.now(); + + // await _userRepository.update(user); + // await _syncApiRepository.ack(event.ack); + } + + if (event.data is SyncUserDeleteV1) { + final data = event.data as SyncUserDeleteV1; + + debugPrint("User delete: $data"); + // await _syncApiRepository.ack(event.ack); + } + } + }); + } + + Future dispose() async { + await _userSyncSubscription?.cancel(); + } +} diff --git a/mobile/lib/entities/asset.entity.dart b/mobile/lib/entities/asset.entity.dart index 4bec35970a..048068ad3d 100644 --- a/mobile/lib/entities/asset.entity.dart +++ b/mobile/lib/entities/asset.entity.dart @@ -1,13 +1,16 @@ import 'dart:convert'; import 'dart:io'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/extensions/string_extensions.dart'; +import 'package:immich_mobile/infrastructure/entities/exif.entity.dart' + as entity; +import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; import 'package:immich_mobile/utils/hash.dart'; import 'package:isar/isar.dart'; import 'package:openapi/api.dart'; -import 'package:photo_manager/photo_manager.dart' show AssetEntity; -import 'package:immich_mobile/extensions/string_extensions.dart'; import 'package:path/path.dart' as p; +import 'package:photo_manager/photo_manager.dart' show AssetEntity; part 'asset.entity.g.dart'; @@ -27,8 +30,9 @@ class Asset { width = remote.exifInfo?.exifImageWidth?.toInt(), livePhotoVideoId = remote.livePhotoVideoId, ownerId = fastHash(remote.ownerId), - exifInfo = - remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null, + exifInfo = remote.exifInfo == null + ? null + : ExifDtoConverter.fromDto(remote.exifInfo!), isFavorite = remote.isFavorite, isArchived = remote.isArchived, isTrashed = remote.isTrashed, @@ -359,14 +363,14 @@ class Asset { localId: localId, width: a.width ?? width, height: a.height ?? height, - exifInfo: a.exifInfo?.copyWith(id: id) ?? exifInfo, + exifInfo: a.exifInfo?.copyWith(assetId: id) ?? exifInfo, ); } else if (isRemote) { return _copyWith( localId: localId ?? a.localId, width: width ?? a.width, height: height ?? a.height, - exifInfo: exifInfo ?? a.exifInfo?.copyWith(id: id), + exifInfo: exifInfo ?? a.exifInfo?.copyWith(assetId: id), ); } else { // TODO: Revisit this and remove all bool field assignments @@ -407,7 +411,7 @@ class Asset { isArchived: a.isArchived, isTrashed: a.isTrashed, isOffline: a.isOffline, - exifInfo: a.exifInfo?.copyWith(id: id) ?? exifInfo, + exifInfo: a.exifInfo?.copyWith(assetId: id) ?? exifInfo, thumbhash: a.thumbhash, ); } else { @@ -416,7 +420,8 @@ class Asset { localId: localId ?? a.localId, width: width ?? a.width, height: height ?? a.height, - exifInfo: exifInfo ?? a.exifInfo?.copyWith(id: id), + exifInfo: exifInfo ?? + a.exifInfo?.copyWith(assetId: id), // updated to use assetId ); } } @@ -476,8 +481,8 @@ class Asset { Future put(Isar db) async { await db.assets.put(this); if (exifInfo != null) { - exifInfo!.id = id; - await db.exifInfos.put(exifInfo!); + await db.exifInfos + .put(entity.ExifInfo.fromDto(exifInfo!.copyWith(assetId: id))); } } @@ -545,19 +550,13 @@ enum AssetType { } extension AssetTypeEnumHelper on AssetTypeEnum { - AssetType toAssetType() { - switch (this) { - case AssetTypeEnum.IMAGE: - return AssetType.image; - case AssetTypeEnum.VIDEO: - return AssetType.video; - case AssetTypeEnum.AUDIO: - return AssetType.audio; - case AssetTypeEnum.OTHER: - return AssetType.other; - } - throw Exception(); - } + AssetType toAssetType() => switch (this) { + AssetTypeEnum.IMAGE => AssetType.image, + AssetTypeEnum.VIDEO => AssetType.video, + AssetTypeEnum.AUDIO => AssetType.audio, + AssetTypeEnum.OTHER => AssetType.other, + _ => throw Exception(), + }; } /// Describes where the information of this asset came from: diff --git a/mobile/lib/entities/exif_info.entity.dart b/mobile/lib/entities/exif_info.entity.dart deleted file mode 100644 index c46f3dddc1..0000000000 --- a/mobile/lib/entities/exif_info.entity.dart +++ /dev/null @@ -1,241 +0,0 @@ -import 'package:isar/isar.dart'; -import 'package:openapi/api.dart'; - -part 'exif_info.entity.g.dart'; - -/// Exif information 1:1 relation with Asset -@Collection(inheritance: false) -class ExifInfo { - Id? id; - int? fileSize; - DateTime? dateTimeOriginal; - String? timeZone; - String? make; - String? model; - String? lens; - float? f; - float? mm; - short? iso; - float? exposureSeconds; - float? lat; - float? long; - String? city; - String? state; - String? country; - String? description; - String? orientation; - - @ignore - bool get hasCoordinates => - latitude != null && longitude != null && latitude != 0 && longitude != 0; - - @ignore - String get exposureTime { - if (exposureSeconds == null) { - return ""; - } else if (exposureSeconds! < 1) { - return "1/${(1.0 / exposureSeconds!).round()} s"; - } else { - return "${exposureSeconds!.toStringAsFixed(1)} s"; - } - } - - @ignore - String get fNumber => f != null ? f!.toStringAsFixed(1) : ""; - - @ignore - String get focalLength => mm != null ? mm!.toStringAsFixed(1) : ""; - - @ignore - bool? _isFlipped; - - @ignore - @pragma('vm:prefer-inline') - bool get isFlipped => _isFlipped ??= _isOrientationFlipped(orientation); - - @ignore - double? get latitude => lat; - - @ignore - double? get longitude => long; - - ExifInfo.fromDto(ExifResponseDto dto) - : fileSize = dto.fileSizeInByte, - dateTimeOriginal = dto.dateTimeOriginal, - timeZone = dto.timeZone, - make = dto.make, - model = dto.model, - lens = dto.lensModel, - f = dto.fNumber?.toDouble(), - mm = dto.focalLength?.toDouble(), - iso = dto.iso?.toInt(), - exposureSeconds = _exposureTimeToSeconds(dto.exposureTime), - lat = dto.latitude?.toDouble(), - long = dto.longitude?.toDouble(), - city = dto.city, - state = dto.state, - country = dto.country, - description = dto.description, - orientation = dto.orientation; - - ExifInfo({ - this.id, - this.fileSize, - this.dateTimeOriginal, - this.timeZone, - this.make, - this.model, - this.lens, - this.f, - this.mm, - this.iso, - this.exposureSeconds, - this.lat, - this.long, - this.city, - this.state, - this.country, - this.description, - this.orientation, - }); - - ExifInfo copyWith({ - Id? id, - int? fileSize, - DateTime? dateTimeOriginal, - String? timeZone, - String? make, - String? model, - String? lens, - float? f, - float? mm, - short? iso, - float? exposureSeconds, - float? lat, - float? long, - String? city, - String? state, - String? country, - String? description, - String? orientation, - }) => - ExifInfo( - id: id ?? this.id, - fileSize: fileSize ?? this.fileSize, - dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal, - timeZone: timeZone ?? this.timeZone, - 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, - lat: lat ?? this.lat, - long: long ?? this.long, - city: city ?? this.city, - state: state ?? this.state, - country: country ?? this.country, - description: description ?? this.description, - orientation: orientation ?? this.orientation, - ); - - @override - bool operator ==(other) { - if (other is! ExifInfo) return false; - return id == other.id && - fileSize == other.fileSize && - dateTimeOriginal == other.dateTimeOriginal && - timeZone == other.timeZone && - make == other.make && - model == other.model && - lens == other.lens && - f == other.f && - mm == other.mm && - iso == other.iso && - exposureSeconds == other.exposureSeconds && - lat == other.lat && - long == other.long && - city == other.city && - state == other.state && - country == other.country && - description == other.description && - orientation == other.orientation; - } - - @override - @ignore - int get hashCode => - id.hashCode ^ - fileSize.hashCode ^ - dateTimeOriginal.hashCode ^ - timeZone.hashCode ^ - make.hashCode ^ - model.hashCode ^ - lens.hashCode ^ - f.hashCode ^ - mm.hashCode ^ - iso.hashCode ^ - exposureSeconds.hashCode ^ - lat.hashCode ^ - long.hashCode ^ - city.hashCode ^ - state.hashCode ^ - country.hashCode ^ - description.hashCode ^ - orientation.hashCode; - - @override - String toString() { - return """ -{ - id: $id, - fileSize: $fileSize, - dateTimeOriginal: $dateTimeOriginal, - timeZone: $timeZone, - make: $make, - model: $model, - lens: $lens, - f: $f, - mm: $mm, - iso: $iso, - exposureSeconds: $exposureSeconds, - lat: $lat, - long: $long, - city: $city, - state: $state, - country: $country, - description: $description, - orientation: $orientation -}"""; - } -} - -bool _isOrientationFlipped(String? orientation) { - final value = orientation != null ? int.tryParse(orientation) : null; - if (value == null) { - return false; - } - final isRotated90CW = value == 5 || value == 6 || value == 90; - final isRotated270CW = value == 7 || value == 8 || value == -90; - return isRotated90CW || isRotated270CW; -} - -double? _exposureTimeToSeconds(String? s) { - if (s == null) { - return null; - } - double? value = double.tryParse(s); - if (value != null) { - return value; - } - final parts = s.split("/"); - if (parts.length == 2) { - final numerator = double.tryParse(parts[0]); - final denominator = double.tryParse(parts[1]); - if (numerator != null && denominator != null) { - return numerator / denominator; - } - } - return null; -} diff --git a/mobile/lib/entities/logger_message.entity.dart b/mobile/lib/entities/logger_message.entity.dart deleted file mode 100644 index d904e19e7a..0000000000 --- a/mobile/lib/entities/logger_message.entity.dart +++ /dev/null @@ -1,50 +0,0 @@ -// ignore_for_file: constant_identifier_names - -import 'package:isar/isar.dart'; -import 'package:logging/logging.dart'; - -part 'logger_message.entity.g.dart'; - -@Collection(inheritance: false) -class LoggerMessage { - Id id = Isar.autoIncrement; - String message; - String? details; - @Enumerated(EnumType.ordinal) - LogLevel level = LogLevel.INFO; - DateTime createdAt; - String? context1; - String? context2; - - LoggerMessage({ - required this.message, - required this.details, - required this.level, - required this.createdAt, - required this.context1, - required this.context2, - }); - - @override - String toString() { - return 'InAppLoggerMessage(message: $message, level: $level, createdAt: $createdAt)'; - } -} - -/// Log levels according to dart logging [Level] -enum LogLevel { - ALL, - FINEST, - FINER, - FINE, - CONFIG, - INFO, - WARNING, - SEVERE, - SHOUT, - OFF, -} - -extension LevelExtension on Level { - LogLevel toLogLevel() => LogLevel.values[Level.LEVELS.indexOf(this)]; -} diff --git a/mobile/lib/entities/store.entity.dart b/mobile/lib/entities/store.entity.dart index a7f2db78d0..ed955352e2 100644 --- a/mobile/lib/entities/store.entity.dart +++ b/mobile/lib/entities/store.entity.dart @@ -1,147 +1,11 @@ import 'dart:convert'; import 'dart:typed_data'; -import 'package:collection/collection.dart'; -import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:isar/isar.dart'; -import 'package:logging/logging.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; -part 'store.entity.g.dart'; - -/// Key-value store for individual items enumerated in StoreKey. -/// Supports String, int and JSON-serializable Objects -/// Can be used concurrently from multiple isolates -class Store { - static final Logger _log = Logger("Store"); - static late final Isar _db; - static final List _cache = - List.filled(StoreKey.values.map((e) => e.id).max + 1, null); - - /// Initializes the store (call exactly once per app start) - static void init(Isar db) { - _db = db; - _populateCache(); - _db.storeValues.where().build().watch().listen(_onChangeListener); - } - - /// clears all values from this store (cache and DB), only for testing! - static Future clear() { - _cache.fillRange(0, _cache.length, null); - return _db.writeTxn(() => _db.storeValues.clear()); - } - - /// Returns the stored value for the given key or if null the [defaultValue] - /// Throws a [StoreKeyNotFoundException] if both are null - static T get(StoreKey key, [T? defaultValue]) { - final value = _cache[key.id] ?? defaultValue; - if (value == null) { - throw StoreKeyNotFoundException(key); - } - return value; - } - - /// Watches a specific key for changes - static Stream watch(StoreKey key) => - _db.storeValues.watchObject(key.id).map((e) => e?._extract(key)); - - /// Returns the stored value for the given key (possibly null) - static T? tryGet(StoreKey key) => _cache[key.id]; - - /// Stores the value synchronously in the cache and asynchronously in the DB - static Future put(StoreKey key, T value) { - if (_cache[key.id] == value) return Future.value(); - _cache[key.id] = value; - return _db.writeTxn( - () async => _db.storeValues.put(await StoreValue._of(value, key)), - ); - } - - /// Removes the value synchronously from the cache and asynchronously from the DB - static Future delete(StoreKey key) { - if (_cache[key.id] == null) return Future.value(); - _cache[key.id] = null; - return _db.writeTxn(() => _db.storeValues.delete(key.id)); - } - - /// Fills the cache with the values from the DB - static _populateCache() { - for (StoreKey key in StoreKey.values) { - final StoreValue? value = _db.storeValues.getSync(key.id); - if (value != null) { - _cache[key.id] = value._extract(key); - } - } - } - - /// updates the state if a value is updated in any isolate - static void _onChangeListener(List? data) { - if (data != null) { - for (StoreValue value in data) { - final key = StoreKey.values.firstWhereOrNull((e) => e.id == value.id); - if (key != null) { - _cache[value.id] = value._extract(key); - } else { - _log.warning("No key available for value id - ${value.id}"); - } - } - } - } -} - -/// Internal class for `Store`, do not use elsewhere. -@Collection(inheritance: false) -class StoreValue { - StoreValue(this.id, {this.intValue, this.strValue}); - Id id; - int? intValue; - String? strValue; - - T? _extract(StoreKey key) { - switch (key.type) { - case const (int): - return intValue as T?; - case const (bool): - return intValue == null ? null : (intValue! == 1) as T; - case const (DateTime): - return intValue == null - ? null - : DateTime.fromMicrosecondsSinceEpoch(intValue!) as T; - case const (String): - return strValue as T?; - default: - if (key.fromDb != null) { - return key.fromDb!.call(Store._db, intValue!); - } - } - throw TypeError(); - } - - static Future _of(T? value, StoreKey key) async { - int? i; - String? s; - switch (key.type) { - case const (int): - i = value as int?; - break; - case const (bool): - i = value == null ? null : (value == true ? 1 : 0); - break; - case const (DateTime): - i = value == null ? null : (value as DateTime).microsecondsSinceEpoch; - break; - case const (String): - s = value as String?; - break; - default: - if (key.toDb != null) { - i = await key.toDb!.call(Store._db, value); - break; - } - throw TypeError(); - } - return StoreValue(key.id, intValue: i, strValue: s); - } -} +// ignore: non_constant_identifier_names +final Store = StoreService.I; class SSLClientCertStoreVal { final Uint8List data; @@ -172,101 +36,3 @@ class SSLClientCertStoreVal { Store.delete(StoreKey.sslClientPasswd); } } - -class StoreKeyNotFoundException implements Exception { - final StoreKey key; - StoreKeyNotFoundException(this.key); - @override - String toString() => "Key '${key.name}' not found in Store"; -} - -/// Key for each possible value in the `Store`. -/// Defines the data type for each value -enum StoreKey { - version(0, type: int), - assetETag(1, type: String), - currentUser(2, type: User, fromDb: _getUser, toDb: _toUser), - deviceIdHash(3, type: int), - deviceId(4, type: String), - backupFailedSince(5, type: DateTime), - backupRequireWifi(6, type: bool), - backupRequireCharging(7, type: bool), - backupTriggerDelay(8, type: int), - serverUrl(10, type: String), - accessToken(11, type: String), - serverEndpoint(12, type: String), - autoBackup(13, type: bool), - backgroundBackup(14, type: bool), - sslClientCertData(15, type: String), - sslClientPasswd(16, type: String), - // user settings from [AppSettingsEnum] below: - loadPreview(100, type: bool), - loadOriginal(101, type: bool), - themeMode(102, type: String), - tilesPerRow(103, type: int), - dynamicLayout(104, type: bool), - groupAssetsBy(105, type: int), - uploadErrorNotificationGracePeriod(106, type: int), - backgroundBackupTotalProgress(107, type: bool), - backgroundBackupSingleProgress(108, type: bool), - storageIndicator(109, type: bool), - thumbnailCacheSize(110, type: int), - imageCacheSize(111, type: int), - albumThumbnailCacheSize(112, type: int), - selectedAlbumSortOrder(113, type: int), - advancedTroubleshooting(114, type: bool), - logLevel(115, type: int), - preferRemoteImage(116, type: bool), - loopVideo(117, type: bool), - // map related settings - mapShowFavoriteOnly(118, type: bool), - mapRelativeDate(119, type: int), - selfSignedCert(120, type: bool), - mapIncludeArchived(121, type: bool), - ignoreIcloudAssets(122, type: bool), - selectedAlbumSortReverse(123, type: bool), - mapThemeMode(124, type: int), - mapwithPartners(125, type: bool), - enableHapticFeedback(126, type: bool), - customHeaders(127, type: String), - - // theme settings - primaryColor(128, type: String), - dynamicTheme(129, type: bool), - colorfulInterface(130, type: bool), - - syncAlbums(131, type: bool), - - // Auto endpoint switching - autoEndpointSwitching(132, type: bool), - preferredWifiName(133, type: String), - localEndpoint(134, type: String), - externalEndpointList(135, type: String), - - // Video settings - loadOriginalVideo(136, type: bool), - ; - - const StoreKey( - this.id, { - required this.type, - this.fromDb, - this.toDb, - }); - final int id; - final Type type; - final T? Function(Isar, int)? fromDb; - final Future Function(Isar, T)? toDb; -} - -T? _getUser(Isar db, int i) { - final User? u = db.users.getSync(i); - return u as T?; -} - -Future _toUser(Isar db, T u) { - if (u is User) { - return db.users.put(u); - } - throw TypeError(); -} diff --git a/mobile/lib/entities/user.entity.dart b/mobile/lib/entities/user.entity.dart index 55a19fe496..8fa6e83874 100644 --- a/mobile/lib/entities/user.entity.dart +++ b/mobile/lib/entities/user.entity.dart @@ -149,56 +149,33 @@ enum AvatarColorEnum { } extension AvatarColorEnumHelper on UserAvatarColor { - AvatarColorEnum toAvatarColor() { - switch (this) { - case UserAvatarColor.primary: - return AvatarColorEnum.primary; - case UserAvatarColor.pink: - return AvatarColorEnum.pink; - case UserAvatarColor.red: - return AvatarColorEnum.red; - case UserAvatarColor.yellow: - return AvatarColorEnum.yellow; - case UserAvatarColor.blue: - return AvatarColorEnum.blue; - case UserAvatarColor.green: - return AvatarColorEnum.green; - case UserAvatarColor.purple: - return AvatarColorEnum.purple; - case UserAvatarColor.orange: - return AvatarColorEnum.orange; - case UserAvatarColor.gray: - return AvatarColorEnum.gray; - case UserAvatarColor.amber: - return AvatarColorEnum.amber; - } - return AvatarColorEnum.primary; - } + AvatarColorEnum toAvatarColor() => switch (this) { + UserAvatarColor.primary => AvatarColorEnum.primary, + UserAvatarColor.pink => AvatarColorEnum.pink, + UserAvatarColor.red => AvatarColorEnum.red, + UserAvatarColor.yellow => AvatarColorEnum.yellow, + UserAvatarColor.blue => AvatarColorEnum.blue, + UserAvatarColor.green => AvatarColorEnum.green, + UserAvatarColor.purple => AvatarColorEnum.purple, + UserAvatarColor.orange => AvatarColorEnum.orange, + UserAvatarColor.gray => AvatarColorEnum.gray, + UserAvatarColor.amber => AvatarColorEnum.amber, + _ => AvatarColorEnum.primary, + }; } extension AvatarColorToColorHelper on AvatarColorEnum { - Color toColor([bool isDarkTheme = false]) { - switch (this) { - case AvatarColorEnum.primary: - return isDarkTheme ? const Color(0xFFABCBFA) : const Color(0xFF4250AF); - case AvatarColorEnum.pink: - return const Color.fromARGB(255, 244, 114, 182); - case AvatarColorEnum.red: - return const Color.fromARGB(255, 239, 68, 68); - case AvatarColorEnum.yellow: - return const Color.fromARGB(255, 234, 179, 8); - case AvatarColorEnum.blue: - return const Color.fromARGB(255, 59, 130, 246); - case AvatarColorEnum.green: - return const Color.fromARGB(255, 22, 163, 74); - case AvatarColorEnum.purple: - return const Color.fromARGB(255, 147, 51, 234); - case AvatarColorEnum.orange: - return const Color.fromARGB(255, 234, 88, 12); - case AvatarColorEnum.gray: - return const Color.fromARGB(255, 75, 85, 99); - case AvatarColorEnum.amber: - return const Color.fromARGB(255, 217, 119, 6); - } - } + Color toColor([bool isDarkTheme = false]) => switch (this) { + AvatarColorEnum.primary => + isDarkTheme ? const Color(0xFFABCBFA) : const Color(0xFF4250AF), + AvatarColorEnum.pink => const Color.fromARGB(255, 244, 114, 182), + AvatarColorEnum.red => const Color.fromARGB(255, 239, 68, 68), + AvatarColorEnum.yellow => const Color.fromARGB(255, 234, 179, 8), + AvatarColorEnum.blue => const Color.fromARGB(255, 59, 130, 246), + AvatarColorEnum.green => const Color.fromARGB(255, 22, 163, 74), + AvatarColorEnum.purple => const Color.fromARGB(255, 147, 51, 234), + AvatarColorEnum.orange => const Color.fromARGB(255, 234, 88, 12), + AvatarColorEnum.gray => const Color.fromARGB(255, 75, 85, 99), + AvatarColorEnum.amber => const Color.fromARGB(255, 217, 119, 6), + }; } diff --git a/mobile/lib/extensions/maplibrecontroller_extensions.dart b/mobile/lib/extensions/maplibrecontroller_extensions.dart index 83074be137..42d5e2c1d0 100644 --- a/mobile/lib/extensions/maplibrecontroller_extensions.dart +++ b/mobile/lib/extensions/maplibrecontroller_extensions.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'dart:math'; import 'package:flutter/services.dart'; @@ -6,7 +7,7 @@ import 'package:immich_mobile/models/map/map_marker.model.dart'; import 'package:immich_mobile/utils/map_utils.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -extension MapMarkers on MaplibreMapController { +extension MapMarkers on MapLibreMapController { static var _completer = Completer()..complete(); Future addGeoJSONSourceForMarkers(List markers) async { @@ -40,11 +41,29 @@ extension MapMarkers on MaplibreMapController { await addGeoJSONSourceForMarkers(markers); - await addHeatmapLayer( - MapUtils.defaultSourceId, - MapUtils.defaultHeatMapLayerId, - MapUtils.defaultHeatMapLayerProperties, - ); + if (Platform.isAndroid) { + await addCircleLayer( + MapUtils.defaultSourceId, + MapUtils.defaultHeatMapLayerId, + const CircleLayerProperties( + circleRadius: 10, + circleColor: "rgba(150,86,34,0.7)", + circleBlur: 1.0, + circleOpacity: 0.7, + circleStrokeWidth: 0.1, + circleStrokeColor: "rgba(203,46,19,0.5)", + circleStrokeOpacity: 0.7, + ), + ); + } + + if (Platform.isIOS) { + await addHeatmapLayer( + MapUtils.defaultSourceId, + MapUtils.defaultHeatMapLayerId, + MapUtils.defaultHeatMapLayerProperties, + ); + } _completer.complete(); } diff --git a/mobile/lib/extensions/theme_extensions.dart b/mobile/lib/extensions/theme_extensions.dart index 3e17e2b991..b81e4476e0 100644 --- a/mobile/lib/extensions/theme_extensions.dart +++ b/mobile/lib/extensions/theme_extensions.dart @@ -10,14 +10,14 @@ extension ImmichColorSchemeExtensions on ColorScheme { extension ColorExtensions on Color { Color lighten({double amount = 0.1}) { return Color.alphaBlend( - Colors.white.withOpacity(amount), + Colors.white.withValues(alpha: amount), this, ); } Color darken({double amount = 0.1}) { return Color.alphaBlend( - Colors.black.withOpacity(amount), + Colors.black.withValues(alpha: amount), this, ); } diff --git a/mobile/lib/infrastructure/README.md b/mobile/lib/infrastructure/README.md new file mode 100644 index 0000000000..8959704270 --- /dev/null +++ b/mobile/lib/infrastructure/README.md @@ -0,0 +1,31 @@ +# Infrastructure Layer + +This directory contains the infrastructure layer of Immich. The infrastructure layer is responsible for the implementation details of the app. It includes data sources, APIs, and other external dependencies. + +## Structure + +- **[Entities](./entities/)**: These are the classes that define the database schema for the domain models. +- **[Repositories](./repositories/)**: These are the actual implementation of the domain interfaces. A single interface might have multiple implementations. +- **[Utils](./utils/)**: These are utility classes and functions specific to infrastructure implementations. + +``` +infrastructure/ +├── entities/ +│ └── user.entity.dart +├── repositories/ +│ └── user.repository.dart +└── utils/ + └── database_utils.dart +``` + +## Usage + +The infrastructure layer provides concrete implementations of repository interfaces defined in the domain layer. These implementations are exposed through Riverpod providers in the root `providers` directory. + +```dart +// In domain/services/user.service.dart +final userRepository = ref.watch(userRepositoryProvider); +final user = await userRepository.getUser(userId); +``` + +The domain layer should never directly instantiate repository implementations, but instead receive them through dependency injection. \ No newline at end of file diff --git a/mobile/lib/infrastructure/entities/exif.entity.dart b/mobile/lib/infrastructure/entities/exif.entity.dart new file mode 100644 index 0000000000..5a93bc9768 --- /dev/null +++ b/mobile/lib/infrastructure/entities/exif.entity.dart @@ -0,0 +1,92 @@ +import 'package:immich_mobile/domain/models/exif.model.dart' as domain; +import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; +import 'package:isar/isar.dart'; + +part 'exif.entity.g.dart'; + +/// Exif information 1:1 relation with Asset +@Collection(inheritance: false) +class ExifInfo { + final Id? id; + final int? fileSize; + final DateTime? dateTimeOriginal; + final String? timeZone; + final String? make; + final String? model; + final String? lens; + final float? f; + final float? mm; + final short? iso; + final float? exposureSeconds; + final float? lat; + final float? long; + final String? city; + final String? state; + final String? country; + final String? description; + final String? orientation; + + const ExifInfo({ + this.id, + this.fileSize, + this.dateTimeOriginal, + this.timeZone, + this.make, + this.model, + this.lens, + this.f, + this.mm, + this.iso, + this.exposureSeconds, + this.lat, + this.long, + this.city, + this.state, + this.country, + this.description, + this.orientation, + }); + + static ExifInfo fromDto(domain.ExifInfo dto) => ExifInfo( + id: dto.assetId, + fileSize: dto.fileSize, + dateTimeOriginal: dto.dateTimeOriginal, + timeZone: dto.timeZone, + make: dto.make, + model: dto.model, + lens: dto.lens, + f: dto.f, + mm: dto.mm, + iso: dto.iso?.toInt(), + exposureSeconds: dto.exposureSeconds, + lat: dto.latitude, + long: dto.longitude, + city: dto.city, + state: dto.state, + country: dto.country, + description: dto.description, + orientation: dto.orientation, + ); + + domain.ExifInfo toDto() => domain.ExifInfo( + assetId: id, + fileSize: fileSize, + description: description, + orientation: orientation, + timeZone: timeZone, + dateTimeOriginal: dateTimeOriginal, + isFlipped: ExifDtoConverter.isOrientationFlipped(orientation), + latitude: lat, + longitude: long, + city: city, + state: state, + country: country, + make: make, + model: model, + lens: lens, + f: f, + mm: mm, + iso: iso?.toInt(), + exposureSeconds: exposureSeconds, + ); +} diff --git a/mobile/lib/entities/exif_info.entity.g.dart b/mobile/lib/infrastructure/entities/exif.entity.g.dart similarity index 99% rename from mobile/lib/entities/exif_info.entity.g.dart rename to mobile/lib/infrastructure/entities/exif.entity.g.dart index 0b744e5f20..989338abff 100644 --- a/mobile/lib/entities/exif_info.entity.g.dart +++ b/mobile/lib/infrastructure/entities/exif.entity.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'exif_info.entity.dart'; +part of 'exif.entity.dart'; // ************************************************************************** // IsarCollectionGenerator @@ -288,9 +288,7 @@ List> _exifInfoGetLinks(ExifInfo object) { return []; } -void _exifInfoAttach(IsarCollection col, Id id, ExifInfo object) { - object.id = id; -} +void _exifInfoAttach(IsarCollection col, Id id, ExifInfo object) {} extension ExifInfoQueryWhereSort on QueryBuilder { QueryBuilder anyId() { diff --git a/mobile/lib/infrastructure/entities/log.entity.dart b/mobile/lib/infrastructure/entities/log.entity.dart new file mode 100644 index 0000000000..6a38924e24 --- /dev/null +++ b/mobile/lib/infrastructure/entities/log.entity.dart @@ -0,0 +1,47 @@ +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:isar/isar.dart'; + +part 'log.entity.g.dart'; + +@Collection(inheritance: false) +class LoggerMessage { + final Id id = Isar.autoIncrement; + final String message; + final String? details; + @Enumerated(EnumType.ordinal) + final LogLevel level; + final DateTime createdAt; + final String? context1; + final String? context2; + + const LoggerMessage({ + required this.message, + required this.details, + this.level = LogLevel.info, + required this.createdAt, + required this.context1, + required this.context2, + }); + + LogMessage toDto() { + return LogMessage( + message: message, + level: level, + createdAt: createdAt, + logger: context1, + error: details, + stack: context2, + ); + } + + static LoggerMessage fromDto(LogMessage log) { + return LoggerMessage( + message: log.message, + details: log.error, + level: log.level, + createdAt: log.createdAt, + context1: log.logger, + context2: log.stack, + ); + } +} diff --git a/mobile/lib/entities/logger_message.entity.g.dart b/mobile/lib/infrastructure/entities/log.entity.g.dart similarity index 98% rename from mobile/lib/entities/logger_message.entity.g.dart rename to mobile/lib/infrastructure/entities/log.entity.g.dart index e292e7173a..9300cf15c5 100644 --- a/mobile/lib/entities/logger_message.entity.g.dart +++ b/mobile/lib/infrastructure/entities/log.entity.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'logger_message.entity.dart'; +part of 'log.entity.dart'; // ************************************************************************** // IsarCollectionGenerator @@ -117,10 +117,9 @@ LoggerMessage _loggerMessageDeserialize( createdAt: reader.readDateTime(offsets[2]), details: reader.readStringOrNull(offsets[3]), level: _LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offsets[4])] ?? - LogLevel.ALL, + LogLevel.info, message: reader.readString(offsets[5]), ); - object.id = id; return object; } @@ -141,7 +140,7 @@ P _loggerMessageDeserializeProp

( return (reader.readStringOrNull(offset)) as P; case 4: return (_LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offset)] ?? - LogLevel.ALL) as P; + LogLevel.info) as P; case 5: return (reader.readString(offset)) as P; default: @@ -150,28 +149,28 @@ P _loggerMessageDeserializeProp

( } const _LoggerMessagelevelEnumValueMap = { - 'ALL': 0, - 'FINEST': 1, - 'FINER': 2, - 'FINE': 3, - 'CONFIG': 4, - 'INFO': 5, - 'WARNING': 6, - 'SEVERE': 7, - 'SHOUT': 8, - 'OFF': 9, + 'all': 0, + 'finest': 1, + 'finer': 2, + 'fine': 3, + 'config': 4, + 'info': 5, + 'warning': 6, + 'severe': 7, + 'shout': 8, + 'off': 9, }; const _LoggerMessagelevelValueEnumMap = { - 0: LogLevel.ALL, - 1: LogLevel.FINEST, - 2: LogLevel.FINER, - 3: LogLevel.FINE, - 4: LogLevel.CONFIG, - 5: LogLevel.INFO, - 6: LogLevel.WARNING, - 7: LogLevel.SEVERE, - 8: LogLevel.SHOUT, - 9: LogLevel.OFF, + 0: LogLevel.all, + 1: LogLevel.finest, + 2: LogLevel.finer, + 3: LogLevel.fine, + 4: LogLevel.config, + 5: LogLevel.info, + 6: LogLevel.warning, + 7: LogLevel.severe, + 8: LogLevel.shout, + 9: LogLevel.off, }; Id _loggerMessageGetId(LoggerMessage object) { @@ -183,9 +182,7 @@ List> _loggerMessageGetLinks(LoggerMessage object) { } void _loggerMessageAttach( - IsarCollection col, Id id, LoggerMessage object) { - object.id = id; -} + IsarCollection col, Id id, LoggerMessage object) {} extension LoggerMessageQueryWhereSort on QueryBuilder { diff --git a/mobile/lib/infrastructure/entities/store.entity.dart b/mobile/lib/infrastructure/entities/store.entity.dart new file mode 100644 index 0000000000..8d6d9a7d16 --- /dev/null +++ b/mobile/lib/infrastructure/entities/store.entity.dart @@ -0,0 +1,13 @@ +import 'package:isar/isar.dart'; + +part 'store.entity.g.dart'; + +/// Internal class for `Store`, do not use elsewhere. +@Collection(inheritance: false) +class StoreValue { + final Id id; + final int? intValue; + final String? strValue; + + const StoreValue(this.id, {this.intValue, this.strValue}); +} diff --git a/mobile/lib/entities/store.entity.g.dart b/mobile/lib/infrastructure/entities/store.entity.g.dart similarity index 99% rename from mobile/lib/entities/store.entity.g.dart rename to mobile/lib/infrastructure/entities/store.entity.g.dart index 7d3210ff85..b97b5b0a28 100644 --- a/mobile/lib/entities/store.entity.g.dart +++ b/mobile/lib/infrastructure/entities/store.entity.g.dart @@ -105,9 +105,7 @@ List> _storeValueGetLinks(StoreValue object) { return []; } -void _storeValueAttach(IsarCollection col, Id id, StoreValue object) { - object.id = id; -} +void _storeValueAttach(IsarCollection col, Id id, StoreValue object) {} extension StoreValueQueryWhereSort on QueryBuilder { diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart new file mode 100644 index 0000000000..74e182bdee --- /dev/null +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -0,0 +1,19 @@ +import 'dart:async'; + +import 'package:immich_mobile/domain/interfaces/db.interface.dart'; +import 'package:isar/isar.dart'; + +// #zoneTxn is the symbol used by Isar to mark a transaction within the current zone +// ref: isar/isar_common.dart +const Symbol _kzoneTxn = #zoneTxn; + +class IsarDatabaseRepository implements IDatabaseRepository { + final Isar _db; + const IsarDatabaseRepository(Isar db) : _db = db; + + // Isar do not support nested transactions. This is a workaround to prevent us from making nested transactions + // Reuse the current transaction if it is already active, else start a new transaction + @override + Future transaction(Future Function() callback) => + Zone.current[_kzoneTxn] == null ? _db.writeTxn(callback) : callback(); +} diff --git a/mobile/lib/infrastructure/repositories/exif.repository.dart b/mobile/lib/infrastructure/repositories/exif.repository.dart new file mode 100644 index 0000000000..2b4276dd57 --- /dev/null +++ b/mobile/lib/infrastructure/repositories/exif.repository.dart @@ -0,0 +1,50 @@ +import 'package:immich_mobile/domain/interfaces/exif.interface.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/infrastructure/entities/exif.entity.dart' + as entity; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:isar/isar.dart'; + +class IsarExifRepository extends IsarDatabaseRepository + implements IExifInfoRepository { + final Isar _db; + + const IsarExifRepository(this._db) : super(_db); + + @override + Future delete(int assetId) async { + await transaction(() async { + await _db.exifInfos.delete(assetId); + }); + } + + @override + Future deleteAll() async { + await transaction(() async { + await _db.exifInfos.clear(); + }); + } + + @override + Future get(int assetId) async { + return (await _db.exifInfos.get(assetId))?.toDto(); + } + + @override + Future update(ExifInfo exifInfo) { + return transaction(() async { + await _db.exifInfos.put(entity.ExifInfo.fromDto(exifInfo)); + return exifInfo; + }); + } + + @override + Future> updateAll(List exifInfos) { + return transaction(() async { + await _db.exifInfos.putAll( + exifInfos.map(entity.ExifInfo.fromDto).toList(), + ); + return exifInfos; + }); + } +} diff --git a/mobile/lib/infrastructure/repositories/log.repository.dart b/mobile/lib/infrastructure/repositories/log.repository.dart new file mode 100644 index 0000000000..6ff128f93b --- /dev/null +++ b/mobile/lib/infrastructure/repositories/log.repository.dart @@ -0,0 +1,53 @@ +import 'package:immich_mobile/domain/interfaces/log.interface.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/infrastructure/entities/log.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:isar/isar.dart'; + +class IsarLogRepository extends IsarDatabaseRepository + implements ILogRepository { + final Isar _db; + const IsarLogRepository(super.db) : _db = db; + + @override + Future deleteAll() async { + await transaction(() async => await _db.loggerMessages.clear()); + return true; + } + + @override + Future> getAll() async { + final logs = + await _db.loggerMessages.where().sortByCreatedAtDesc().findAll(); + return logs.map((l) => l.toDto()).toList(); + } + + @override + Future insert(LogMessage log) async { + final logEntity = LoggerMessage.fromDto(log); + await transaction(() async { + await _db.loggerMessages.put(logEntity); + }); + return true; + } + + @override + Future insertAll(Iterable logs) async { + await transaction(() async { + final logEntities = + logs.map((log) => LoggerMessage.fromDto(log)).toList(); + await _db.loggerMessages.putAll(logEntities); + }); + return true; + } + + @override + Future truncate({int limit = 250}) async { + await transaction(() async { + final count = await _db.loggerMessages.count(); + if (count <= limit) return; + final toRemove = count - limit; + await _db.loggerMessages.where().limit(toRemove).deleteAll(); + }); + } +} diff --git a/mobile/lib/infrastructure/repositories/store.repository.dart b/mobile/lib/infrastructure/repositories/store.repository.dart new file mode 100644 index 0000000000..5cf6838ee1 --- /dev/null +++ b/mobile/lib/infrastructure/repositories/store.repository.dart @@ -0,0 +1,101 @@ +import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; +import 'package:isar/isar.dart'; + +class IsarStoreRepository extends IsarDatabaseRepository + implements IStoreRepository { + final Isar _db; + const IsarStoreRepository(super.db) : _db = db; + + @override + Future deleteAll() async { + return await transaction(() async { + await _db.storeValues.clear(); + return true; + }); + } + + @override + Stream watchAll() { + return _db.storeValues.where().watch(fireImmediately: true).asyncExpand( + (entities) => + Stream.fromFutures(entities.map((e) async => _toUpdateEvent(e))), + ); + } + + @override + Future delete(StoreKey key) async { + return await transaction(() async => await _db.storeValues.delete(key.id)); + } + + @override + Future insert(StoreKey key, T value) async { + return await transaction(() async { + await _db.storeValues.put(await _fromValue(key, value)); + return true; + }); + } + + @override + Future tryGet(StoreKey key) async { + final entity = (await _db.storeValues.get(key.id)); + if (entity == null) { + return null; + } + return await _toValue(key, entity); + } + + @override + Future update(StoreKey key, T value) async { + return await transaction(() async { + await _db.storeValues.put(await _fromValue(key, value)); + return true; + }); + } + + @override + Stream watch(StoreKey key) async* { + yield* _db.storeValues + .watchObject(key.id, fireImmediately: true) + .asyncMap((e) async => e == null ? null : await _toValue(key, e)); + } + + Future _toUpdateEvent(StoreValue entity) async { + final key = StoreKey.values.firstWhere((e) => e.id == entity.id); + final value = await _toValue(key, entity); + return StoreUpdateEvent(key, value); + } + + Future _toValue(StoreKey key, StoreValue entity) async => + switch (key.type) { + const (int) => entity.intValue, + const (String) => entity.strValue, + const (bool) => entity.intValue == 1, + const (DateTime) => entity.intValue == null + ? null + : DateTime.fromMillisecondsSinceEpoch(entity.intValue!), + const (User) => await UserRepository(_db).getByDbId(entity.intValue!), + _ => null, + } as T?; + + Future _fromValue(StoreKey key, T value) async { + final (int? intValue, String? strValue) = switch (key.type) { + const (int) => (value as int, null), + const (String) => (null, value as String), + const (bool) => ((value as bool) ? 1 : 0, null), + const (DateTime) => ((value as DateTime).millisecondsSinceEpoch, null), + const (User) => ( + (await UserRepository(_db).update(value as User)).isarId, + null, + ), + _ => throw UnsupportedError( + "Unsupported primitive type: ${key.type} for key: ${key.name}", + ), + }; + return StoreValue(key.id, intValue: intValue, strValue: strValue); + } +} diff --git a/mobile/lib/infrastructure/repositories/sync_api.repository.dart b/mobile/lib/infrastructure/repositories/sync_api.repository.dart new file mode 100644 index 0000000000..88a6838c44 --- /dev/null +++ b/mobile/lib/infrastructure/repositories/sync_api.repository.dart @@ -0,0 +1,112 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/domain/interfaces/sync_api.interface.dart'; +import 'package:immich_mobile/domain/models/sync/sync_event.model.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:openapi/api.dart'; +import 'package:http/http.dart' as http; + +class SyncApiRepository implements ISyncApiRepository { + final ApiService _api; + const SyncApiRepository(this._api); + + @override + Stream> watchUserSyncEvent() { + return _getSyncStream( + SyncStreamDto(types: [SyncRequestType.usersV1]), + ); + } + + @override + Future ack(String data) { + return _api.syncApi.sendSyncAck(SyncAckSetDto(acks: [data])); + } + + Stream> _getSyncStream( + SyncStreamDto dto, { + int batchSize = 5000, + }) async* { + final client = http.Client(); + final endpoint = "${_api.apiClient.basePath}/sync/stream"; + + final headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/jsonlines+json', + }; + + final queryParams = []; + final headerParams = {}; + await _api.applyToParams(queryParams, headerParams); + headers.addAll(headerParams); + + final request = http.Request('POST', Uri.parse(endpoint)); + request.headers.addAll(headers); + request.body = jsonEncode(dto.toJson()); + + String previousChunk = ''; + List lines = []; + + try { + final response = await client.send(request); + + if (response.statusCode != 200) { + final errorBody = await response.stream.bytesToString(); + throw ApiException( + response.statusCode, + 'Failed to get sync stream: $errorBody', + ); + } + + await for (final chunk in response.stream.transform(utf8.decoder)) { + previousChunk += chunk; + final parts = previousChunk.split('\n'); + previousChunk = parts.removeLast(); + lines.addAll(parts); + + if (lines.length < batchSize) { + continue; + } + + yield await compute(_parseSyncResponse, lines); + lines.clear(); + } + } finally { + if (lines.isNotEmpty) { + yield await compute(_parseSyncResponse, lines); + } + client.close(); + } + } +} + +const _kResponseMap = { + SyncEntityType.userV1: SyncUserV1.fromJson, + SyncEntityType.userDeleteV1: SyncUserDeleteV1.fromJson, +}; + +// Need to be outside of the class to be able to use compute +List _parseSyncResponse(List lines) { + final List data = []; + + for (var line in lines) { + try { + final jsonData = jsonDecode(line); + final type = SyncEntityType.fromJson(jsonData['type'])!; + final dataJson = jsonData['data']; + final ack = jsonData['ack']; + final converter = _kResponseMap[type]; + if (converter == null) { + debugPrint("[_parseSyncReponse] Unknown type $type"); + continue; + } + + data.add(SyncEvent(data: converter(dataJson), ack: ack)); + } catch (error, stack) { + debugPrint("[_parseSyncReponse] Error parsing json $error $stack"); + } + } + + return data; +} diff --git a/mobile/lib/infrastructure/utils/exif.converter.dart b/mobile/lib/infrastructure/utils/exif.converter.dart new file mode 100644 index 0000000000..0f6e2b0295 --- /dev/null +++ b/mobile/lib/infrastructure/utils/exif.converter.dart @@ -0,0 +1,56 @@ +import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:openapi/api.dart'; + +abstract final class ExifDtoConverter { + static ExifInfo fromDto(ExifResponseDto dto) { + return ExifInfo( + fileSize: dto.fileSizeInByte, + description: dto.description, + orientation: dto.orientation, + timeZone: dto.timeZone, + dateTimeOriginal: dto.dateTimeOriginal, + isFlipped: isOrientationFlipped(dto.orientation), + latitude: dto.latitude?.toDouble(), + longitude: dto.longitude?.toDouble(), + city: dto.city, + state: dto.state, + country: dto.country, + make: dto.make, + model: dto.model, + lens: dto.lensModel, + f: dto.fNumber?.toDouble(), + mm: dto.focalLength?.toDouble(), + iso: dto.iso?.toInt(), + exposureSeconds: _exposureTimeToSeconds(dto.exposureTime), + ); + } + + static bool isOrientationFlipped(String? orientation) { + final value = orientation == null ? null : int.tryParse(orientation); + if (value == null) { + return false; + } + final isRotated90CW = value == 5 || value == 6 || value == 90; + final isRotated270CW = value == 7 || value == 8 || value == -90; + return isRotated90CW || isRotated270CW; + } + + static double? _exposureTimeToSeconds(String? s) { + if (s == null) { + return null; + } + double? value = double.tryParse(s); + if (value != null) { + return value; + } + final parts = s.split("/"); + if (parts.length == 2) { + final numerator = double.tryParse(parts.firstOrNull ?? "-"); + final denominator = double.tryParse(parts.lastOrNull ?? "-"); + if (numerator != null && denominator != null) { + return numerator / denominator; + } + } + return null; + } +} diff --git a/mobile/lib/interfaces/album.interface.dart b/mobile/lib/interfaces/album.interface.dart index cabf2dee53..3a83a8feb7 100644 --- a/mobile/lib/interfaces/album.interface.dart +++ b/mobile/lib/interfaces/album.interface.dart @@ -42,6 +42,14 @@ abstract interface class IAlbumRepository implements IDatabaseRepository { Future recalculateMetadata(Album album); Future> search(String searchTerm, QuickFilterMode filterMode); + + Stream> watchRemoteAlbums(); + + Stream> watchLocalAlbums(); + + Stream watchAlbum(int id); + + Future clearTable(); } enum AlbumSort { remoteId, localId } diff --git a/mobile/lib/interfaces/asset.interface.dart b/mobile/lib/interfaces/asset.interface.dart index 5aec594eb1..83a020f843 100644 --- a/mobile/lib/interfaces/asset.interface.dart +++ b/mobile/lib/interfaces/asset.interface.dart @@ -41,7 +41,7 @@ abstract interface class IAssetRepository implements IDatabaseRepository { Future deleteAllByRemoteId(List ids, {AssetState? state}); - Future deleteById(List ids); + Future deleteByIds(List ids); Future> getMatches({ required List assets, @@ -57,6 +57,17 @@ abstract interface class IAssetRepository implements IDatabaseRepository { Future upsertDuplicatedAssets(Iterable duplicatedAssets); Future> getAllDuplicatedAssetIds(); + + Future> getStackAssets(String stackId); + + Future clearTable(); + + Stream watchAsset(int id, {bool fireImmediately = false}); + + Future> getTrashAssets(int userId); + + Future> getRecentlyAddedAssets(int userId); + Future> getMotionAssets(int userId); } enum AssetSort { checksum, ownerIdChecksum } diff --git a/mobile/lib/interfaces/backup.interface.dart b/mobile/lib/interfaces/backup_album.interface.dart similarity index 85% rename from mobile/lib/interfaces/backup.interface.dart rename to mobile/lib/interfaces/backup_album.interface.dart index c32199a58f..f98adb6821 100644 --- a/mobile/lib/interfaces/backup.interface.dart +++ b/mobile/lib/interfaces/backup_album.interface.dart @@ -1,7 +1,7 @@ import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/interfaces/database.interface.dart'; -abstract interface class IBackupRepository implements IDatabaseRepository { +abstract interface class IBackupAlbumRepository implements IDatabaseRepository { Future> getAll({BackupAlbumSort? sort}); Future> getIdsBySelection(BackupSelection backup); diff --git a/mobile/lib/interfaces/etag.interface.dart b/mobile/lib/interfaces/etag.interface.dart index e567235d1b..22942b0e34 100644 --- a/mobile/lib/interfaces/etag.interface.dart +++ b/mobile/lib/interfaces/etag.interface.dart @@ -11,4 +11,6 @@ abstract interface class IETagRepository implements IDatabaseRepository { Future upsertAll(List etags); Future deleteByIds(List ids); + + Future clearTable(); } diff --git a/mobile/lib/interfaces/exif_info.interface.dart b/mobile/lib/interfaces/exif_info.interface.dart deleted file mode 100644 index 86608c26d0..0000000000 --- a/mobile/lib/interfaces/exif_info.interface.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:immich_mobile/entities/exif_info.entity.dart'; -import 'package:immich_mobile/interfaces/database.interface.dart'; - -abstract interface class IExifInfoRepository implements IDatabaseRepository { - Future get(int id); - - Future update(ExifInfo exifInfo); - - Future> updateAll(List exifInfos); - - Future delete(int id); -} diff --git a/mobile/lib/interfaces/folder_api.interface.dart b/mobile/lib/interfaces/folder_api.interface.dart new file mode 100644 index 0000000000..68c1652e21 --- /dev/null +++ b/mobile/lib/interfaces/folder_api.interface.dart @@ -0,0 +1,6 @@ +import 'package:immich_mobile/entities/asset.entity.dart'; + +abstract interface class IFolderApiRepository { + Future> getAllUniquePaths(); + Future> getAssetsForPath(String? path); +} diff --git a/mobile/lib/interfaces/partner.interface.dart b/mobile/lib/interfaces/partner.interface.dart new file mode 100644 index 0000000000..995e07c392 --- /dev/null +++ b/mobile/lib/interfaces/partner.interface.dart @@ -0,0 +1,8 @@ +import 'package:immich_mobile/entities/user.entity.dart'; + +abstract class IPartnerRepository { + Future> getSharedWith(); + Future> getSharedBy(); + Stream> watchSharedWith(); + Stream> watchSharedBy(); +} diff --git a/mobile/lib/interfaces/person_api.interface.dart b/mobile/lib/interfaces/person_api.interface.dart index b2fa28df8c..9d127ad765 100644 --- a/mobile/lib/interfaces/person_api.interface.dart +++ b/mobile/lib/interfaces/person_api.interface.dart @@ -1,3 +1,6 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:convert'; + abstract interface class IPersonApiRepository { Future> getAll(); Future update(String id, {String? name}); @@ -6,10 +9,10 @@ abstract interface class IPersonApiRepository { class Person { Person({ required this.id, + this.birthDate, required this.isHidden, required this.name, required this.thumbnailPath, - this.birthDate, this.updatedAt, }); @@ -19,4 +22,80 @@ class Person { 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)'; + } + + Person copyWith({ + String? id, + DateTime? birthDate, + bool? isHidden, + String? name, + String? thumbnailPath, + DateTime? updatedAt, + }) { + return Person( + 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 Person.fromMap(Map map) { + return Person( + 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 Person.fromJson(String source) => + Person.fromMap(json.decode(source) as Map); + + @override + bool operator ==(covariant Person 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; + } } diff --git a/mobile/lib/interfaces/timeline.interface.dart b/mobile/lib/interfaces/timeline.interface.dart new file mode 100644 index 0000000000..d43f87ed5b --- /dev/null +++ b/mobile/lib/interfaces/timeline.interface.dart @@ -0,0 +1,31 @@ +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; + +abstract class ITimelineRepository { + Future> getTimelineUserIds(int id); + + Stream> watchTimelineUsers(int id); + + Stream watchArchiveTimeline(int userId); + Stream watchFavoriteTimeline(int userId); + Stream watchTrashTimeline(int userId); + Stream watchAlbumTimeline( + Album album, + GroupAssetsBy groupAssetsBy, + ); + Stream watchAllVideosTimeline(); + + Stream watchHomeTimeline(int userId, GroupAssetsBy groupAssetsBy); + Stream watchMultiUsersTimeline( + List userIds, + GroupAssetsBy groupAssetsBy, + ); + + Future getTimelineFromAssets( + List assets, + GroupAssetsBy getGroupByOption, + ); + + Stream watchAssetSelectionTimeline(int userId); +} diff --git a/mobile/lib/interfaces/user.interface.dart b/mobile/lib/interfaces/user.interface.dart index e6175a7dc9..d099e0e50b 100644 --- a/mobile/lib/interfaces/user.interface.dart +++ b/mobile/lib/interfaces/user.interface.dart @@ -4,6 +4,8 @@ import 'package:immich_mobile/interfaces/database.interface.dart'; abstract interface class IUserRepository implements IDatabaseRepository { Future get(String id); + Future getByDbId(int id); + Future> getByIds(List ids); Future> getAll({bool self = true, UserSort? sortBy}); @@ -18,6 +20,8 @@ abstract interface class IUserRepository implements IDatabaseRepository { Future deleteById(List ids); Future me(); + + Future clearTable(); } enum UserSort { id } diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 139366b359..407ea86d59 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -4,56 +4,48 @@ import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; -import 'package:intl/date_symbol_data_local.dart'; -import 'package:timezone/data/latest.dart'; -import 'package:isar/isar.dart'; -import 'package:logging/logging.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/constants/locales.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/locale_provider.dart'; import 'package:immich_mobile/providers/theme.provider.dart'; -import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/tab_navigation_observer.dart'; -import 'package:immich_mobile/entities/backup_album.entity.dart'; -import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; -import 'package:immich_mobile/entities/album.entity.dart'; -import 'package:immich_mobile/entities/android_device_asset.entity.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/etag.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; -import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; -import 'package:immich_mobile/entities/logger_message.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/services/background.service.dart'; -import 'package:immich_mobile/services/immich_logger.service.dart'; import 'package:immich_mobile/services/local_notification.service.dart'; -import 'package:immich_mobile/utils/migration.dart'; -import 'package:immich_mobile/utils/download.dart'; -import 'package:immich_mobile/utils/cache/widgets_binding.dart'; -import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; -import 'package:immich_mobile/theme/theme_data.dart'; import 'package:immich_mobile/theme/dynamic_theme.dart'; +import 'package:immich_mobile/theme/theme_data.dart'; +import 'package:immich_mobile/utils/bootstrap.dart'; +import 'package:immich_mobile/utils/cache/widgets_binding.dart'; +import 'package:immich_mobile/utils/download.dart'; +import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; +import 'package:immich_mobile/utils/migration.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:logging/logging.dart'; +import 'package:timezone/data/latest.dart'; void main() async { ImmichWidgetsBinding(); - final db = await loadDb(); + final db = await Bootstrap.initIsar(); + await Bootstrap.initDomain(db); await initApp(); await migrateDatabaseIfNeeded(db); HttpOverrides.global = HttpSSLCertOverride(); runApp( ProviderScope( - overrides: [dbProvider.overrideWithValue(db)], + overrides: [ + dbProvider.overrideWithValue(db), + isarProvider.overrideWithValue(db), + ], child: const MainWidget(), ), ); @@ -74,9 +66,6 @@ Future initApp() async { await DynamicTheme.fetchSystemPalette(); - // Initialize Immich Logger Service - ImmichLogger(); - final log = Logger("ImmichErrorLogger"); FlutterError.onError = (details) { @@ -116,29 +105,6 @@ Future initApp() async { await FileDownloader().trackTasks(); } -Future loadDb() async { - final dir = await getApplicationDocumentsDirectory(); - Isar db = await Isar.open( - [ - StoreValueSchema, - ExifInfoSchema, - AssetSchema, - AlbumSchema, - UserSchema, - BackupAlbumSchema, - DuplicatedAssetSchema, - LoggerMessageSchema, - ETagSchema, - if (Platform.isAndroid) AndroidDeviceAssetSchema, - if (Platform.isIOS) IOSDeviceAssetSchema, - ], - directory: dir.path, - maxSizeMiB: 1024, - ); - Store.init(db); - return db; -} - class ImmichApp extends ConsumerStatefulWidget { const ImmichApp({super.key}); diff --git a/mobile/lib/mixins/error_logger.mixin.dart b/mobile/lib/mixins/error_logger.mixin.dart index 9b2bc6f98e..482420a978 100644 --- a/mobile/lib/mixins/error_logger.mixin.dart +++ b/mobile/lib/mixins/error_logger.mixin.dart @@ -6,8 +6,9 @@ typedef AsyncFuture = Future>; mixin ErrorLoggerMixin { abstract final Logger logger; + // ignore: unintended_html_in_doc_comment /// Returns an AsyncValue if the future is successfully executed - /// Else, logs the error to the overrided logger and returns an AsyncError<> + /// Else, logs the error to the overridden logger and returns an AsyncError<> AsyncFuture guardError( Future Function() fn, { required String errorMessage, diff --git a/mobile/lib/models/folder/recursive_folder.model.dart b/mobile/lib/models/folder/recursive_folder.model.dart new file mode 100644 index 0000000000..5b54a2e1bf --- /dev/null +++ b/mobile/lib/models/folder/recursive_folder.model.dart @@ -0,0 +1,11 @@ +import 'package:immich_mobile/models/folder/root_folder.model.dart'; + +class RecursiveFolder extends RootFolder { + final String name; + + RecursiveFolder({ + required this.name, + required super.path, + required super.subfolders, + }); +} diff --git a/mobile/lib/models/folder/root_folder.model.dart b/mobile/lib/models/folder/root_folder.model.dart new file mode 100644 index 0000000000..8f72a539c0 --- /dev/null +++ b/mobile/lib/models/folder/root_folder.model.dart @@ -0,0 +1,11 @@ +import 'package:immich_mobile/models/folder/recursive_folder.model.dart'; + +class RootFolder { + final List subfolders; + final String path; + + RootFolder({ + required this.subfolders, + required this.path, + }); +} diff --git a/mobile/lib/models/search/search_curated_content.model.dart b/mobile/lib/models/search/search_curated_content.model.dart index af660bad9d..a3d74941b3 100644 --- a/mobile/lib/models/search/search_curated_content.model.dart +++ b/mobile/lib/models/search/search_curated_content.model.dart @@ -8,20 +8,26 @@ 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; 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, ); } @@ -29,6 +35,7 @@ class SearchCuratedContent { Map toMap() { return { 'label': label, + 'subtitle': subtitle, 'id': id, }; } @@ -36,6 +43,7 @@ class SearchCuratedContent { factory SearchCuratedContent.fromMap(Map map) { return SearchCuratedContent( label: map['label'] as String, + subtitle: map['subtitle'] as String?, id: map['id'] as String, ); } @@ -46,13 +54,14 @@ class SearchCuratedContent { SearchCuratedContent.fromMap(json.decode(source) as Map); @override - String toString() => 'CuratedContent(label: $label, id: $id)'; + 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.id == id; + return other.label == label && other.subtitle == subtitle && other.id == id; } @override diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 297a819b6a..87e7b24e34 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -235,6 +235,7 @@ class SearchDisplayFilters { class SearchFilter { String? context; String? filename; + String? description; Set people; SearchLocationFilter location; SearchCameraFilter camera; @@ -247,6 +248,7 @@ class SearchFilter { SearchFilter({ this.context, this.filename, + this.description, required this.people, required this.location, required this.camera, @@ -255,9 +257,28 @@ class SearchFilter { required this.mediaType, }); + bool get isEmpty { + return (context == null || (context != null && context!.isEmpty)) && + (filename == null || (filename!.isEmpty)) && + (description == null || (description!.isEmpty)) && + people.isEmpty && + location.country == null && + location.state == null && + location.city == null && + camera.make == null && + camera.model == null && + date.takenBefore == null && + date.takenAfter == null && + display.isNotInAlbum == false && + display.isArchive == false && + display.isFavorite == false && + mediaType == AssetType.other; + } + SearchFilter copyWith({ String? context, String? filename, + String? description, Set? people, SearchLocationFilter? location, SearchCameraFilter? camera, @@ -268,6 +289,7 @@ class SearchFilter { return SearchFilter( context: context ?? this.context, filename: filename ?? this.filename, + description: description ?? this.description, people: people ?? this.people, location: location ?? this.location, camera: camera ?? this.camera, @@ -279,7 +301,7 @@ class SearchFilter { @override String toString() { - return 'SearchFilter(context: $context, filename: $filename, people: $people, location: $location, camera: $camera, date: $date, display: $display, mediaType: $mediaType)'; + return 'SearchFilter(context: $context, filename: $filename, description: $description, people: $people, location: $location, camera: $camera, date: $date, display: $display, mediaType: $mediaType)'; } @override @@ -288,6 +310,7 @@ class SearchFilter { return other.context == context && other.filename == filename && + other.description == description && other.people == people && other.location == location && other.camera == camera && @@ -300,6 +323,7 @@ class SearchFilter { int get hashCode { return context.hashCode ^ filename.hashCode ^ + description.hashCode ^ people.hashCode ^ location.hashCode ^ camera.hashCode ^ diff --git a/mobile/lib/pages/album/album_additional_shared_user_selection.page.dart b/mobile/lib/pages/album/album_additional_shared_user_selection.page.dart index 02026b828d..5096a8a249 100644 --- a/mobile/lib/pages/album/album_additional_shared_user_selection.page.dart +++ b/mobile/lib/pages/album/album_additional_shared_user_selection.page.dart @@ -53,7 +53,7 @@ class AlbumAdditionalSharedUserSelectionPage extends HookConsumerWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: Chip( - backgroundColor: context.primaryColor.withOpacity(0.15), + backgroundColor: context.primaryColor.withValues(alpha: 0.15), label: Text( user.name, style: const TextStyle( diff --git a/mobile/lib/pages/album/album_asset_selection.page.dart b/mobile/lib/pages/album/album_asset_selection.page.dart index 18ceb3e144..ac7f3dbedc 100644 --- a/mobile/lib/pages/album/album_asset_selection.page.dart +++ b/mobile/lib/pages/album/album_asset_selection.page.dart @@ -6,11 +6,10 @@ 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/models/albums/asset_selection_page_result.model.dart'; -import 'package:immich_mobile/providers/asset_viewer/render_list.provider.dart'; +import 'package:immich_mobile/providers/timeline.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:isar/isar.dart'; @RoutePage() class AlbumAssetSelectionPage extends HookConsumerWidget { @@ -18,16 +17,14 @@ class AlbumAssetSelectionPage extends HookConsumerWidget { super.key, required this.existingAssets, this.canDeselect = false, - required this.query, }); final Set existingAssets; - final QueryBuilder? query; final bool canDeselect; @override Widget build(BuildContext context, WidgetRef ref) { - final renderList = ref.watch(renderListQueryProvider(query)); + final assetSelectionRenderList = ref.watch(assetSelectionTimelineProvider); final selected = useState>(existingAssets); final selectionEnabledHook = useState(true); @@ -83,7 +80,7 @@ class AlbumAssetSelectionPage extends HookConsumerWidget { ), ], ), - body: renderList.widgetWhen( + body: assetSelectionRenderList.widgetWhen( onData: (data) => buildBody(data), ), ); diff --git a/mobile/lib/pages/album/album_shared_user_selection.page.dart b/mobile/lib/pages/album/album_shared_user_selection.page.dart index ed8a45194d..61ef267b99 100644 --- a/mobile/lib/pages/album/album_shared_user_selection.page.dart +++ b/mobile/lib/pages/album/album_shared_user_selection.page.dart @@ -72,7 +72,7 @@ class AlbumSharedUserSelectionPage extends HookConsumerWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: Chip( - backgroundColor: context.primaryColor.withOpacity(0.15), + backgroundColor: context.primaryColor.withValues(alpha: 0.15), label: Text( user.email, style: const TextStyle( diff --git a/mobile/lib/pages/album/album_viewer.dart b/mobile/lib/pages/album/album_viewer.dart index 19782c4e30..75b2c09af3 100644 --- a/mobile/lib/pages/album/album_viewer.dart +++ b/mobile/lib/pages/album/album_viewer.dart @@ -14,13 +14,13 @@ import 'package:immich_mobile/pages/album/album_shared_user_icons.dart'; import 'package:immich_mobile/pages/album/album_title.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; +import 'package:immich_mobile/providers/timeline.provider.dart'; import 'package:immich_mobile/utils/immich_loading_overlay.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/widgets/album/album_viewer_appbar.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -62,7 +62,6 @@ class AlbumViewer extends HookConsumerWidget { AlbumAssetSelectionRoute( existingAssets: album.assets, canDeselect: false, - query: getRemoteAssetQuery(ref), ), ); @@ -104,7 +103,7 @@ class AlbumViewer extends HookConsumerWidget { children: [ MultiselectGrid( key: const ValueKey("albumViewerMultiselectGrid"), - renderListProvider: albumRenderlistProvider(album.id), + renderListProvider: albumTimelineProvider(album.id), topWidget: Column( mainAxisAlignment: MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.start, diff --git a/mobile/lib/pages/album/album_viewer.page.dart b/mobile/lib/pages/album/album_viewer.page.dart index 491bd3bb8d..146a93a0a6 100644 --- a/mobile/lib/pages/album/album_viewer.page.dart +++ b/mobile/lib/pages/album/album_viewer.page.dart @@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/pages/album/album_viewer.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; +import 'package:immich_mobile/providers/timeline.provider.dart'; @RoutePage() class AlbumViewerPage extends HookConsumerWidget { @@ -16,6 +17,9 @@ class AlbumViewerPage extends HookConsumerWidget { // Listen provider to prevent autoDispose when navigating to other routes from within the viewer page ref.listen(currentAlbumProvider, (_, __) {}); + // This call helps rendering the asset selection instantly + ref.listen(assetSelectionTimelineProvider, (_, __) {}); + ref.listen(albumWatcher(albumId), (_, albumFuture) { albumFuture.whenData( (value) => ref.read(currentAlbumProvider.notifier).set(value), diff --git a/mobile/lib/pages/albums/albums.page.dart b/mobile/lib/pages/albums/albums.page.dart index ac6bd2f2fb..e5758c959c 100644 --- a/mobile/lib/pages/albums/albums.page.dart +++ b/mobile/lib/pages/albums/albums.page.dart @@ -106,9 +106,9 @@ class AlbumsPage extends HookConsumerWidget { borderRadius: BorderRadius.circular(24), gradient: LinearGradient( colors: [ - context.colorScheme.primary.withOpacity(0.075), - context.colorScheme.primary.withOpacity(0.09), - context.colorScheme.primary.withOpacity(0.075), + context.colorScheme.primary.withValues(alpha: 0.075), + context.colorScheme.primary.withValues(alpha: 0.09), + context.colorScheme.primary.withValues(alpha: 0.075), ], begin: Alignment.topLeft, end: Alignment.bottomRight, diff --git a/mobile/lib/pages/common/app_log.page.dart b/mobile/lib/pages/common/app_log.page.dart index fd718ee37d..359a541de0 100644 --- a/mobile/lib/pages/common/app_log.page.dart +++ b/mobile/lib/pages/common/app_log.page.dart @@ -2,10 +2,11 @@ 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/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/routing/router.dart'; -import 'package:immich_mobile/entities/logger_message.entity.dart'; import 'package:immich_mobile/services/immich_logger.service.dart'; import 'package:intl/intl.dart'; @@ -17,8 +18,11 @@ class AppLogPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final immichLogger = ImmichLogger(); - final logMessages = useState(immichLogger.messages); + final immichLogger = LogService.I; + final shouldReload = useState(false); + final logMessages = useFuture( + useMemoized(() => immichLogger.getMessages(), [shouldReload.value]), + ); Widget colorStatusIndicator(Color color) { return Column( @@ -36,32 +40,19 @@ class AppLogPage extends HookConsumerWidget { ); } - Widget buildLeadingIcon(LogLevel level) { - switch (level) { - case LogLevel.INFO: - return colorStatusIndicator(context.primaryColor); - case LogLevel.SEVERE: - return colorStatusIndicator(Colors.redAccent); + Widget buildLeadingIcon(LogLevel level) => switch (level) { + LogLevel.info => colorStatusIndicator(context.primaryColor), + LogLevel.severe => colorStatusIndicator(Colors.redAccent), + LogLevel.warning => colorStatusIndicator(Colors.orangeAccent), + _ => colorStatusIndicator(Colors.grey), + }; - case LogLevel.WARNING: - return colorStatusIndicator(Colors.orangeAccent); - default: - return colorStatusIndicator(Colors.grey); - } - } - - getTileColor(LogLevel level) { - switch (level) { - case LogLevel.INFO: - return Colors.transparent; - case LogLevel.SEVERE: - return Colors.redAccent.withOpacity(0.25); - case LogLevel.WARNING: - return Colors.orangeAccent.withOpacity(0.25); - default: - return context.primaryColor.withOpacity(0.1); - } - } + Color getTileColor(LogLevel level) => switch (level) { + LogLevel.info => Colors.transparent, + LogLevel.severe => Colors.redAccent.withValues(alpha: 0.25), + LogLevel.warning => Colors.orangeAccent.withValues(alpha: 0.25), + _ => context.primaryColor.withValues(alpha: 0.1), + }; return Scaffold( appBar: AppBar( @@ -84,7 +75,7 @@ class AppLogPage extends HookConsumerWidget { ), onPressed: () { immichLogger.clearLogs(); - logMessages.value = []; + shouldReload.value = !shouldReload.value; }, ), Builder( @@ -97,7 +88,7 @@ class AppLogPage extends HookConsumerWidget { size: 20.0, ), onPressed: () { - immichLogger.shareLogs(iconContext); + ImmichLogger.shareLogs(iconContext); }, ); }, @@ -118,9 +109,9 @@ class AppLogPage extends HookConsumerWidget { separatorBuilder: (context, index) { return const Divider(height: 0); }, - itemCount: logMessages.value.length, + itemCount: logMessages.data?.length ?? 0, itemBuilder: (context, index) { - var logMessage = logMessages.value[index]; + var logMessage = logMessages.data![index]; return ListTile( onTap: () => context.pushRoute( AppLogDetailRoute( @@ -141,7 +132,7 @@ class AppLogPage extends HookConsumerWidget { ), ), subtitle: Text( - "at ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)} in ${logMessage.context1}", + "at ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)} in ${logMessage.logger}", style: TextStyle( fontSize: 12.0, color: context.colorScheme.onSurfaceSecondary, diff --git a/mobile/lib/pages/common/app_log_detail.page.dart b/mobile/lib/pages/common/app_log_detail.page.dart index dd6af81728..1bfea44ba1 100644 --- a/mobile/lib/pages/common/app_log_detail.page.dart +++ b/mobile/lib/pages/common/app_log_detail.page.dart @@ -1,15 +1,15 @@ 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/entities/logger_message.entity.dart'; import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; @RoutePage() class AppLogDetailPage extends HookConsumerWidget { const AppLogDetailPage({super.key, required this.logMessage}); - final LoggerMessage logMessage; + final LogMessage logMessage; @override Widget build(BuildContext context, WidgetRef ref) { @@ -126,14 +126,14 @@ class AppLogDetailPage extends HookConsumerWidget { child: ListView( children: [ buildTextWithCopyButton("MESSAGE", logMessage.message), - if (logMessage.details != null) - buildTextWithCopyButton("DETAILS", logMessage.details.toString()), - if (logMessage.context1 != null) - buildLogContext1(logMessage.context1.toString()), - if (logMessage.context2 != null) + if (logMessage.error != null) + buildTextWithCopyButton("DETAILS", logMessage.error.toString()), + if (logMessage.logger != null) + buildLogContext1(logMessage.logger.toString()), + if (logMessage.stack != null) buildTextWithCopyButton( "STACK TRACE", - logMessage.context2.toString(), + logMessage.stack.toString(), ), ], ), diff --git a/mobile/lib/pages/common/create_album.page.dart b/mobile/lib/pages/common/create_album.page.dart index 55261f6d55..86bf47c1a7 100644 --- a/mobile/lib/pages/common/create_album.page.dart +++ b/mobile/lib/pages/common/create_album.page.dart @@ -8,7 +8,6 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/albums/asset_selection_page_result.model.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/album_title.provider.dart'; -import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/album/album_action_filled_button.dart'; import 'package:immich_mobile/widgets/album/album_title_text_field.dart'; @@ -54,7 +53,6 @@ class CreateAlbumPage extends HookConsumerWidget { AlbumAssetSelectionRoute( existingAssets: selectedAssets.value, canDeselect: true, - query: getRemoteAssetQuery(ref), ), ); if (selectedAsset == null) { diff --git a/mobile/lib/pages/common/download_panel.dart b/mobile/lib/pages/common/download_panel.dart index 4421e337e9..5cc6e5b8d6 100644 --- a/mobile/lib/pages/common/download_panel.dart +++ b/mobile/lib/pages/common/download_panel.dart @@ -74,26 +74,16 @@ class DownloadTaskTile extends StatelessWidget { Widget build(BuildContext context) { final progressPercent = (progress * 100).round(); - getStatusText() { - switch (status) { - case TaskStatus.running: - return 'downloading'.tr(); - case TaskStatus.complete: - return 'download_complete'.tr(); - case TaskStatus.failed: - return 'download_failed'.tr(); - case TaskStatus.canceled: - return 'download_canceled'.tr(); - case TaskStatus.paused: - return 'download_paused'.tr(); - case TaskStatus.enqueued: - return 'download_enqueue'.tr(); - case TaskStatus.notFound: - return 'download_notfound'.tr(); - case TaskStatus.waitingToRetry: - return 'download_waiting_to_retry'.tr(); - } - } + 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(), + }; return SizedBox( key: const ValueKey('download_progress'), diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 7e47c1d087..f51be027f5 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -262,6 +262,11 @@ class GalleryViewerPage extends HookConsumerWidget { PhotoViewGalleryPageOptions buildAsset(BuildContext context, int index) { var newAsset = loadAsset(index); + + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(currentAssetProvider.notifier).set(newAsset); + }); + final stackId = newAsset.stackId; if (stackId != null && currentIndex.value == index) { final stackElements = diff --git a/mobile/lib/pages/common/headers_settings.page.dart b/mobile/lib/pages/common/headers_settings.page.dart index 7f6ee3e4e2..8674a3cbde 100644 --- a/mobile/lib/pages/common/headers_settings.page.dart +++ b/mobile/lib/pages/common/headers_settings.page.dart @@ -3,9 +3,10 @@ import 'dart:convert'; 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:immich_mobile/entities/store.entity.dart' as store_keys; +import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; class SettingsHeader { String key = ""; @@ -22,8 +23,7 @@ class HeaderSettingsPage extends HookConsumerWidget { final headers = useState>([]); final setInitialHeaders = useState(false); - var headersStr = - store_keys.Store.get(store_keys.StoreKey.customHeaders, ""); + var headersStr = Store.get(StoreKey.customHeaders, ""); if (!setInitialHeaders.value) { if (headersStr.isNotEmpty) { var customHeaders = jsonDecode(headersStr) as Map; @@ -99,7 +99,7 @@ class HeaderSettingsPage extends HookConsumerWidget { } var encoded = jsonEncode(headersMap); - store_keys.Store.put(store_keys.StoreKey.customHeaders, encoded); + Store.put(StoreKey.customHeaders, encoded); } } diff --git a/mobile/lib/pages/common/large_leading_tile.dart b/mobile/lib/pages/common/large_leading_tile.dart index f2cb9f19ae..4f22a5f2b2 100644 --- a/mobile/lib/pages/common/large_leading_tile.dart +++ b/mobile/lib/pages/common/large_leading_tile.dart @@ -16,6 +16,8 @@ class LargeLeadingTile extends StatelessWidget { this.trailing, this.selected = false, this.disabled = false, + this.selectedTileColor, + this.tileColor, }); final Widget leading; @@ -27,6 +29,9 @@ class LargeLeadingTile extends StatelessWidget { final Widget? trailing; final bool selected; final bool disabled; + final Color? selectedTileColor; + final Color? tileColor; + @override Widget build(BuildContext context) { return InkWell( @@ -35,8 +40,9 @@ class LargeLeadingTile extends StatelessWidget { child: Container( decoration: BoxDecoration( color: selected - ? Theme.of(context).primaryColor.withAlpha(30) - : Colors.transparent, + ? selectedTileColor ?? + Theme.of(context).primaryColor.withAlpha(30) + : tileColor ?? Colors.transparent, borderRadius: BorderRadius.circular(borderRadius), ), child: Row( diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index ad9d53b1bb..23685db274 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -5,6 +5,7 @@ import 'package:auto_route/auto_route.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/models/store.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; @@ -26,6 +27,7 @@ import 'package:wakelock_plus/wakelock_plus.dart'; class NativeVideoViewerPage extends HookConsumerWidget { final Asset asset; final bool showControls; + final int playbackDelayFactor; final Widget image; const NativeVideoViewerPage({ @@ -33,6 +35,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { required this.asset, required this.image, this.showControls = true, + this.playbackDelayFactor = 1, }); @override @@ -317,12 +320,16 @@ class NativeVideoViewerPage extends HookConsumerWidget { } // Delay the video playback to avoid a stutter in the swipe animation + // Note, in some circumstances a longer delay is needed (eg: memories), + // the playbackDelayFactor can be used for this + // This delay seems like a hacky way to resolve underlying bugs in video + // playback, but other resolutions failed thus far Timer( Platform.isIOS - ? const Duration(milliseconds: 300) + ? Duration(milliseconds: 300 * playbackDelayFactor) : imageToVideo - ? const Duration(milliseconds: 200) - : const Duration(milliseconds: 400), () { + ? Duration(milliseconds: 200 * playbackDelayFactor) + : Duration(milliseconds: 400 * playbackDelayFactor), () { if (!context.mounted) { return; } diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart index 6a060e19f0..5ea9351c0e 100644 --- a/mobile/lib/pages/common/splash_screen.page.dart +++ b/mobile/lib/pages/common/splash_screen.page.dart @@ -1,11 +1,12 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/backup/backup.provider.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:logging/logging.dart'; @RoutePage() diff --git a/mobile/lib/pages/common/tab_controller.page.dart b/mobile/lib/pages/common/tab_controller.page.dart index 1ba9650056..a418e8d2f0 100644 --- a/mobile/lib/pages/common/tab_controller.page.dart +++ b/mobile/lib/pages/common/tab_controller.page.dart @@ -20,6 +20,8 @@ class TabControllerPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final isRefreshingAssets = ref.watch(assetProvider); final isRefreshingRemoteAlbums = ref.watch(isRefreshingRemoteAlbumProvider); + final isScreenLandscape = + MediaQuery.orientationOf(context) == Orientation.landscape; Widget buildIcon({required Widget icon, required bool isProcessing}) { if (!isProcessing) return icon; @@ -45,7 +47,7 @@ class TabControllerPage extends HookConsumerWidget { ); } - onNavigationSelected(TabsRouter router, int index) { + void onNavigationSelected(TabsRouter router, int index) { // On Photos page menu tapped if (router.activeIndex == 0 && index == 0) { scrollToTopNotifierProvider.scrollToTop(); @@ -61,62 +63,82 @@ class TabControllerPage extends HookConsumerWidget { ref.read(tabProvider.notifier).state = TabEnum.values[index]; } - bottomNavigationBar(TabsRouter tabsRouter) { + final navigationDestinations = [ + NavigationDestination( + label: 'tab_controller_nav_photos'.tr(), + icon: const Icon( + Icons.photo_library_outlined, + ), + selectedIcon: buildIcon( + isProcessing: isRefreshingAssets, + icon: Icon( + Icons.photo_library, + color: context.primaryColor, + ), + ), + ), + NavigationDestination( + label: 'tab_controller_nav_search'.tr(), + icon: const Icon( + Icons.search_rounded, + ), + selectedIcon: Icon( + Icons.search, + color: context.primaryColor, + ), + ), + NavigationDestination( + label: 'albums'.tr(), + icon: const Icon( + Icons.photo_album_outlined, + ), + selectedIcon: buildIcon( + isProcessing: isRefreshingRemoteAlbums, + icon: Icon( + Icons.photo_album_rounded, + color: context.primaryColor, + ), + ), + ), + NavigationDestination( + label: 'library'.tr(), + icon: const Icon( + Icons.space_dashboard_outlined, + ), + selectedIcon: buildIcon( + isProcessing: isRefreshingAssets, + icon: Icon( + Icons.space_dashboard_rounded, + color: context.primaryColor, + ), + ), + ), + ]; + + Widget bottomNavigationBar(TabsRouter tabsRouter) { return NavigationBar( selectedIndex: tabsRouter.activeIndex, onDestinationSelected: (index) => onNavigationSelected(tabsRouter, index), - destinations: [ - NavigationDestination( - label: 'tab_controller_nav_photos'.tr(), - icon: const Icon( - Icons.photo_library_outlined, - ), - selectedIcon: buildIcon( - isProcessing: isRefreshingAssets, - icon: Icon( - Icons.photo_library, - color: context.primaryColor, + destinations: navigationDestinations, + ); + } + + Widget navigationRail(TabsRouter tabsRouter) { + return NavigationRail( + destinations: navigationDestinations + .map( + (e) => NavigationRailDestination( + icon: e.icon, + label: Text(e.label), ), - ), - ), - NavigationDestination( - label: 'tab_controller_nav_search'.tr(), - icon: const Icon( - Icons.search_rounded, - ), - selectedIcon: Icon( - Icons.search, - color: context.primaryColor, - ), - ), - NavigationDestination( - label: 'albums'.tr(), - icon: const Icon( - Icons.photo_album_outlined, - ), - selectedIcon: buildIcon( - isProcessing: isRefreshingRemoteAlbums, - icon: Icon( - Icons.photo_album_rounded, - color: context.primaryColor, - ), - ), - ), - NavigationDestination( - label: 'library'.tr(), - icon: const Icon( - Icons.space_dashboard_outlined, - ), - selectedIcon: buildIcon( - isProcessing: isRefreshingAssets, - icon: Icon( - Icons.space_dashboard_rounded, - color: context.primaryColor, - ), - ), - ), - ], + ) + .toList(), + onDestinationSelected: (index) => + onNavigationSelected(tabsRouter, index), + selectedIndex: tabsRouter.activeIndex, + labelType: NavigationRailLabelType.all, + groupAlignment: 0.0, ); } @@ -135,17 +157,27 @@ class TabControllerPage extends HookConsumerWidget { ), builder: (context, child) { final tabsRouter = AutoTabsRouter.of(context); + final heroedChild = HeroControllerScope( + controller: HeroController(), + child: child, + ); return PopScope( canPop: tabsRouter.activeIndex == 0, onPopInvokedWithResult: (didPop, _) => !didPop ? tabsRouter.setActiveIndex(0) : null, child: Scaffold( - body: HeroControllerScope( - controller: HeroController(), - child: child, - ), - bottomNavigationBar: - multiselectEnabled ? null : bottomNavigationBar(tabsRouter), + body: isScreenLandscape + ? Row( + children: [ + navigationRail(tabsRouter), + const VerticalDivider(), + Expanded(child: heroedChild), + ], + ) + : heroedChild, + bottomNavigationBar: multiselectEnabled || isScreenLandscape + ? null + : bottomNavigationBar(tabsRouter), ), ); }, diff --git a/mobile/lib/pages/editing/crop.page.dart b/mobile/lib/pages/editing/crop.page.dart index dc467f5740..f7f459c770 100644 --- a/mobile/lib/pages/editing/crop.page.dart +++ b/mobile/lib/pages/editing/crop.page.dart @@ -174,33 +174,19 @@ class _AspectRatioButton extends StatelessWidget { @override Widget build(BuildContext context) { - IconData iconData; - switch (label) { - case 'Free': - iconData = Icons.crop_free_rounded; - break; - case '1:1': - iconData = Icons.crop_square_rounded; - break; - case '16:9': - iconData = Icons.crop_16_9_rounded; - break; - case '3:2': - iconData = Icons.crop_3_2_rounded; - break; - case '7:5': - iconData = Icons.crop_7_5_rounded; - break; - default: - iconData = Icons.crop_free_rounded; - } - return Column( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: Icon( - iconData, + switch (label) { + 'Free' => Icons.crop_free_rounded, + '1:1' => Icons.crop_square_rounded, + '16:9' => Icons.crop_16_9_rounded, + '3:2' => Icons.crop_3_2_rounded, + '7:5' => Icons.crop_7_5_rounded, + _ => Icons.crop_free_rounded, + }, color: aspectRatio.value == ratio ? context.primaryColor : context.themeData.iconTheme.color, diff --git a/mobile/lib/pages/editing/edit.page.dart b/mobile/lib/pages/editing/edit.page.dart index 385140eb59..9f2e6a2266 100644 --- a/mobile/lib/pages/editing/edit.page.dart +++ b/mobile/lib/pages/editing/edit.page.dart @@ -127,7 +127,7 @@ class EditImagePage extends ConsumerWidget { borderRadius: BorderRadius.circular(7), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.2), + color: Colors.black.withValues(alpha: 0.2), spreadRadius: 2, blurRadius: 10, offset: const Offset(0, 3), diff --git a/mobile/lib/pages/library/archive.page.dart b/mobile/lib/pages/library/archive.page.dart index 0082142113..a13adc21f2 100644 --- a/mobile/lib/pages/library/archive.page.dart +++ b/mobile/lib/pages/library/archive.page.dart @@ -2,8 +2,8 @@ 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/providers/archive.provider.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; +import 'package:immich_mobile/providers/timeline.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; @RoutePage() @@ -13,8 +13,8 @@ class ArchivePage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { AppBar buildAppBar() { - final archivedAssets = ref.watch(archiveProvider); - final count = archivedAssets.value?.totalAssets.toString() ?? "?"; + final archiveRenderList = ref.watch(archiveTimelineProvider); + final count = archiveRenderList.value?.totalAssets.toString() ?? "?"; return AppBar( leading: IconButton( onPressed: () => context.maybePop(), @@ -31,7 +31,7 @@ class ArchivePage extends HookConsumerWidget { return Scaffold( appBar: ref.watch(multiselectProvider) ? null : buildAppBar(), body: MultiselectGrid( - renderListProvider: archiveProvider, + renderListProvider: archiveTimelineProvider, unarchive: true, archiveEnabled: true, deleteEnabled: true, diff --git a/mobile/lib/pages/library/favorite.page.dart b/mobile/lib/pages/library/favorite.page.dart index cc422f88c7..55e3937166 100644 --- a/mobile/lib/pages/library/favorite.page.dart +++ b/mobile/lib/pages/library/favorite.page.dart @@ -2,8 +2,8 @@ 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/providers/favorite.provider.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; +import 'package:immich_mobile/providers/timeline.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; @RoutePage() @@ -29,7 +29,7 @@ class FavoritesPage extends HookConsumerWidget { return Scaffold( appBar: ref.watch(multiselectProvider) ? null : buildAppBar(), body: MultiselectGrid( - renderListProvider: favoriteAssetsProvider, + renderListProvider: favoriteTimelineProvider, favoriteEnabled: true, editEnabled: true, unfavorite: true, diff --git a/mobile/lib/pages/library/folder/folder.page.dart b/mobile/lib/pages/library/folder/folder.page.dart new file mode 100644 index 0000000000..af6f295970 --- /dev/null +++ b/mobile/lib/pages/library/folder/folder.page.dart @@ -0,0 +1,320 @@ +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/constants/enums.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.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'; +import 'package:immich_mobile/providers/folder.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/bytes_units.dart'; +import 'package:immich_mobile/widgets/asset_grid/thumbnail_image.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; + +RecursiveFolder? _findFolderInStructure( + RootFolder rootFolder, + RecursiveFolder targetFolder, +) { + for (final folder in rootFolder.subfolders) { + if (targetFolder.path == '/' && + folder.path.isEmpty && + folder.name == targetFolder.name) { + return folder; + } + + if (folder.path == targetFolder.path && folder.name == targetFolder.name) { + return folder; + } + + if (folder.subfolders.isNotEmpty) { + final found = _findFolderInStructure(folder, targetFolder); + if (found != null) return found; + } + } + return null; +} + +@RoutePage() +class FolderPage extends HookConsumerWidget { + final RecursiveFolder? folder; + + const FolderPage({super.key, this.folder}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final folderState = ref.watch(folderStructureProvider); + final currentFolder = useState(folder); + final sortOrder = useState(SortOrder.asc); + + useEffect( + () { + if (folder == null) { + ref + .read(folderStructureProvider.notifier) + .fetchFolders(sortOrder.value); + } + return null; + }, + [], + ); + + // Update current folder when root structure changes + useEffect( + () { + if (folder != null && folderState.hasValue) { + final updatedFolder = + _findFolderInStructure(folderState.value!, folder!); + if (updatedFolder != null) { + currentFolder.value = updatedFolder; + } + } + return null; + }, + [folderState], + ); + + void onToggleSortOrder() { + final newOrder = + sortOrder.value == SortOrder.asc ? SortOrder.desc : SortOrder.asc; + + ref.read(folderStructureProvider.notifier).fetchFolders(newOrder); + + sortOrder.value = newOrder; + } + + return Scaffold( + appBar: AppBar( + title: Text(currentFolder.value?.name ?? tr("folders")), + elevation: 0, + centerTitle: false, + actions: [ + IconButton( + icon: const Icon(Icons.swap_vert), + onPressed: onToggleSortOrder, + ), + ], + ), + body: folderState.when( + data: (rootFolder) { + if (folder == null) { + return FolderContent( + folder: rootFolder, + root: rootFolder, + sortOrder: sortOrder.value, + ); + } else { + return FolderContent( + folder: currentFolder.value!, + root: rootFolder, + sortOrder: sortOrder.value, + ); + } + }, + 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()); + }, + ), + ); + } +} + +class FolderContent extends HookConsumerWidget { + final RootFolder? folder; + final RootFolder root; + final SortOrder sortOrder; + + const FolderContent({ + super.key, + this.folder, + required this.root, + this.sortOrder = SortOrder.asc, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final folderRenderlist = ref.watch(folderRenderListProvider(folder!)); + + // Initial asset fetch + useEffect( + () { + if (folder == null) return; + ref + .read(folderRenderListProvider(folder!).notifier) + .fetchAssets(sortOrder); + return null; + }, + [folder], + ); + + if (folder == null) { + return Center(child: const Text("folder_not_found").tr()); + } + + getSubtitle(int subFolderCount) { + if (subFolderCount > 0) { + return "$subFolderCount ${tr("folders")}".toLowerCase(); + } + + if (subFolderCount == 1) { + return "1 ${tr("folder")}".toLowerCase(); + } + + return ""; + } + + return Column( + children: [ + FolderPath(currentFolder: folder!, root: root), + Expanded( + child: folderRenderlist.when( + data: (list) { + if (folder!.subfolders.isEmpty && list.isEmpty) { + return Center(child: const Text("empty_folder").tr()); + } + + return ListView( + children: [ + if (folder!.subfolders.isNotEmpty) + ...folder!.subfolders.map( + (subfolder) => LargeLeadingTile( + leading: Icon( + Icons.folder, + color: context.primaryColor, + size: 48, + ), + title: Text( + subfolder.name, + softWrap: false, + overflow: TextOverflow.ellipsis, + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + subtitle: subfolder.subfolders.isNotEmpty + ? Text( + getSubtitle(subfolder.subfolders.length), + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), + ) + : null, + onTap: () => + context.pushRoute(FolderRoute(folder: subfolder)), + ), + ), + if (!list.isEmpty && + list.allAssets != null && + list.allAssets!.isNotEmpty) + ...list.allAssets!.map( + (asset) => LargeLeadingTile( + onTap: () => context.pushRoute( + GalleryViewerRoute( + renderList: list, + initialIndex: list.allAssets!.indexOf(asset), + ), + ), + leading: ClipRRect( + borderRadius: const BorderRadius.all( + Radius.circular(15), + ), + child: SizedBox( + width: 80, + height: 80, + child: ThumbnailImage( + asset: asset, + showStorageIndicator: false, + ), + ), + ), + title: Text( + asset.fileName, + maxLines: 2, + softWrap: false, + overflow: TextOverflow.ellipsis, + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text( + "${asset.exifInfo?.fileSize != null ? formatBytes(asset.exifInfo?.fileSize ?? 0) : ""} • ${DateFormat.yMMMd().format(asset.fileCreatedAt)}", + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), + ), + ), + ), + ], + ); + }, + 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()); + }, + ), + ), + ], + ); + } +} + +class FolderPath extends StatelessWidget { + final RootFolder currentFolder; + final RootFolder root; + + const FolderPath({ + super.key, + required this.currentFolder, + required this.root, + }); + + @override + Widget build(BuildContext context) { + if (currentFolder.path.isEmpty || currentFolder.path == '/') { + return const SizedBox.shrink(); + } + + return Container( + width: double.infinity, + alignment: Alignment.centerLeft, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text( + currentFolder.path, + style: TextStyle( + fontFamily: 'Inconsolata', + fontWeight: FontWeight.bold, + fontSize: 14, + color: context.colorScheme.onSurface.withAlpha(175), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/pages/library/library.page.dart b/mobile/lib/pages/library/library.page.dart index 92fe8cec17..31b465ead7 100644 --- a/mobile/lib/pages/library/library.page.dart +++ b/mobile/lib/pages/library/library.page.dart @@ -128,6 +128,19 @@ class QuickAccessButtons extends ConsumerWidget { bottomRight: Radius.circular(partners.isEmpty ? 20 : 0), ), ), + leading: const Icon( + Icons.folder_outlined, + size: 26, + ), + title: Text( + 'folders'.tr(), + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + onTap: () => context.pushRoute(FolderRoute()), + ), + ListTile( leading: const Icon( Icons.group_outlined, size: 26, diff --git a/mobile/lib/pages/library/partner/partner_detail.page.dart b/mobile/lib/pages/library/partner/partner_detail.page.dart index 0874aacfa7..f018726fe2 100644 --- a/mobile/lib/pages/library/partner/partner_detail.page.dart +++ b/mobile/lib/pages/library/partner/partner_detail.page.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/providers/multiselect.provider.dart'; import 'package:immich_mobile/providers/partner.provider.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; +import 'package:immich_mobile/providers/timeline.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -110,7 +111,7 @@ class PartnerDetailPage extends HookConsumerWidget { ), ), ), - renderListProvider: assetsProvider(partner.isarId), + renderListProvider: singleUserTimelineProvider(partner.isarId), onRefresh: () => ref.read(assetProvider.notifier).getAllAsset(), deleteEnabled: false, favoriteEnabled: false, diff --git a/mobile/lib/pages/library/people/people_collection.page.dart b/mobile/lib/pages/library/people/people_collection.page.dart index 6c62d70058..c859e96ff2 100644 --- a/mobile/lib/pages/library/people/people_collection.page.dart +++ b/mobile/lib/pages/library/people/people_collection.page.dart @@ -1,8 +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: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/providers/search/people.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/api.service.dart'; @@ -16,6 +18,8 @@ class PeopleCollectionPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final people = ref.watch(getAllPeopleProvider); final headers = ApiService.getRequestHeaders(); + final formFocus = useFocusNode(); + final ValueNotifier search = useState(null); showNameEditModel( String personId, @@ -36,10 +40,70 @@ class PeopleCollectionPage extends HookConsumerWidget { return Scaffold( appBar: AppBar( - title: Text('people'.tr()), + automaticallyImplyLeading: search.value == null, + title: search.value != null + ? TextField( + focusNode: formFocus, + onTapOutside: (_) => formFocus.unfocus(), + onChanged: (value) => search.value = value, + decoration: InputDecoration( + contentPadding: const EdgeInsets.only(left: 24), + filled: true, + fillColor: context.primaryColor.withValues(alpha: 0.1), + hintStyle: context.textTheme.bodyLarge?.copyWith( + color: context.themeData.colorScheme.onSurfaceSecondary, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.surfaceContainerHighest, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.surfaceContainerHighest, + ), + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.surfaceContainerHighest, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.primary.withAlpha(150), + ), + ), + prefixIcon: Icon( + Icons.search_rounded, + color: context.colorScheme.primary, + ), + hintText: 'search_filter_people_hint'.tr(), + ), + autofocus: true, + ) + : Text('people'.tr()), + actions: [ + IconButton( + icon: Icon(search.value != null ? Icons.close : Icons.search), + onPressed: () { + search.value = search.value == null ? '' : null; + }, + ), + ], ), body: people.when( data: (people) { + if (search.value != null) { + people = people.where((person) { + return person.name + .toLowerCase() + .contains(search.value!.toLowerCase()); + }).toList(); + } return GridView.builder( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: isTablet ? 6 : 3, diff --git a/mobile/lib/pages/library/places/places_collection.page.dart b/mobile/lib/pages/library/places/places_collection.page.dart index f42febc373..d4da3ff37e 100644 --- a/mobile/lib/pages/library/places/places_collection.page.dart +++ b/mobile/lib/pages/library/places/places_collection.page.dart @@ -3,6 +3,7 @@ import 'package:cached_network_image/cached_network_image.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/store.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; 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 7ad9943a89..f3afdb4a37 100644 --- a/mobile/lib/pages/library/shared_link/shared_link.page.dart +++ b/mobile/lib/pages/library/shared_link/shared_link.page.dart @@ -58,7 +58,8 @@ class SharedLinkPage extends HookConsumerWidget { child: Icon( Icons.link_off, size: 100, - color: context.themeData.iconTheme.color?.withOpacity(0.5), + color: + context.themeData.iconTheme.color?.withValues(alpha: 0.5), ), ), ), 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 82819c94bd..4b311ba554 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 @@ -120,7 +120,7 @@ class SharedLinkEditPage extends HookConsumerWidget { fontSize: 14, ), disabledBorder: OutlineInputBorder( - borderSide: BorderSide(color: Colors.grey.withOpacity(0.5)), + borderSide: BorderSide(color: Colors.grey.withValues(alpha: 0.5)), ), ), onTapOutside: (_) => descriptionFocusNode.unfocus(), @@ -146,7 +146,7 @@ class SharedLinkEditPage extends HookConsumerWidget { fontSize: 14, ), disabledBorder: OutlineInputBorder( - borderSide: BorderSide(color: Colors.grey.withOpacity(0.5)), + borderSide: BorderSide(color: Colors.grey.withValues(alpha: 0.5)), ), ), ); diff --git a/mobile/lib/pages/library/trash.page.dart b/mobile/lib/pages/library/trash.page.dart index 61c87e19a1..8a969c8e9a 100644 --- a/mobile/lib/pages/library/trash.page.dart +++ b/mobile/lib/pages/library/trash.page.dart @@ -6,6 +6,8 @@ import 'package:fluttertoast/fluttertoast.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/providers/asset.provider.dart'; +import 'package:immich_mobile/providers/timeline.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart'; import 'package:immich_mobile/providers/trash.provider.dart'; @@ -21,7 +23,7 @@ class TrashPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final trashedAssets = ref.watch(trashedAssetsProvider); + final trashRenderList = ref.watch(trashTimelineProvider); final trashDays = ref.watch(serverInfoProvider.select((v) => v.serverConfig.trashDays)); final selectionEnabledHook = useState(false); @@ -67,8 +69,8 @@ class TrashPage extends HookConsumerWidget { try { if (selection.value.isNotEmpty) { final isRemoved = await ref - .read(trashProvider.notifier) - .removeAssets(selection.value); + .read(assetProvider.notifier) + .deleteAssets(selection.value, force: true); if (isRemoved) { if (context.mounted) { @@ -233,11 +235,11 @@ class TrashPage extends HookConsumerWidget { } return Scaffold( - appBar: trashedAssets.maybeWhen( + appBar: trashRenderList.maybeWhen( orElse: () => buildAppBar("?"), data: (data) => buildAppBar(data.totalAssets.toString()), ), - body: trashedAssets.widgetWhen( + body: trashRenderList.widgetWhen( onData: (data) => data.isEmpty ? Center( child: Text('trash_page_no_assets'.tr()), diff --git a/mobile/lib/pages/onboarding/permission_onboarding.page.dart b/mobile/lib/pages/onboarding/permission_onboarding.page.dart index e5408f2297..dc9923303b 100644 --- a/mobile/lib/pages/onboarding/permission_onboarding.page.dart +++ b/mobile/lib/pages/onboarding/permission_onboarding.page.dart @@ -136,23 +136,16 @@ class PermissionOnboardingPage extends HookConsumerWidget { ); } - final Widget child; - switch (permission) { - case PermissionStatus.limited: - child = buildPermissionLimited(); - break; - case PermissionStatus.denied: - child = buildRequestPermission(); - break; - case PermissionStatus.granted: - case PermissionStatus.provisional: - child = buildPermissionGranted(); - break; - case PermissionStatus.restricted: - case PermissionStatus.permanentlyDenied: - child = buildPermissionDenied(); - break; - } + final Widget child = switch (permission) { + PermissionStatus.limited => buildPermissionLimited(), + PermissionStatus.denied => buildRequestPermission(), + PermissionStatus.granted || + PermissionStatus.provisional => + buildPermissionGranted(), + PermissionStatus.restricted || + PermissionStatus.permanentlyDenied => + buildPermissionDenied() + }; return Scaffold( body: SafeArea( diff --git a/mobile/lib/pages/photos/memory.page.dart b/mobile/lib/pages/photos/memory.page.dart index 74a94ed6ee..211472f27a 100644 --- a/mobile/lib/pages/photos/memory.page.dart +++ b/mobile/lib/pages/photos/memory.page.dart @@ -1,10 +1,13 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/models/memories/memory.model.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/widgets/common/immich_image.dart'; import 'package:immich_mobile/widgets/memories/memory_bottom_info.dart'; @@ -13,6 +16,8 @@ import 'package:immich_mobile/widgets/memories/memory_epilogue.dart'; import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart'; @RoutePage() + +/// Expects [currentAssetProvider] to be set before navigating to this page class MemoryPage extends HookConsumerWidget { final List memories; final int memoryIndex; @@ -32,6 +37,7 @@ class MemoryPage extends HookConsumerWidget { "${currentAssetPage.value + 1}|${currentMemory.value.assets.length}", ); const bgColor = Colors.black; + final currentAsset = useState(null); /// The list of all of the asset page controllers final memoryAssetPageControllers = @@ -56,6 +62,37 @@ class MemoryPage extends HookConsumerWidget { ); } + void toPreviousMemory() { + if (currentMemoryIndex.value > 0) { + // Move to the previous memory page + memoryPageController.previousPage( + duration: const Duration(milliseconds: 500), + curve: Curves.easeIn, + ); + + // Wait for the next frame to ensure the page is built + SchedulerBinding.instance.addPostFrameCallback((_) { + final previousIndex = currentMemoryIndex.value - 1; + final previousMemoryController = + memoryAssetPageControllers[previousIndex]; + + // Ensure the controller is attached + if (previousMemoryController.hasClients) { + previousMemoryController + .jumpToPage(memories[previousIndex].assets.length - 1); + } else { + // Wait for the next frame until it is attached + SchedulerBinding.instance.addPostFrameCallback((_) { + if (previousMemoryController.hasClients) { + previousMemoryController + .jumpToPage(memories[previousIndex].assets.length - 1); + } + }); + } + }); + } + } + toNextAsset(int currentAssetIndex) { if (currentAssetIndex + 1 < currentMemory.value.assets.length) { // Go to the next asset @@ -72,6 +109,22 @@ class MemoryPage extends HookConsumerWidget { } } + toPreviousAsset(int currentAssetIndex) { + if (currentAssetIndex > 0) { + // Go to the previous asset + PageController controller = + memoryAssetPageControllers[currentMemoryIndex.value]; + + controller.previousPage( + curve: Curves.easeInOut, + duration: const Duration(milliseconds: 500), + ); + } else { + // Go to the previous memory since we are at the end of our assets + toPreviousMemory(); + } + } + updateProgressText() { assetProgress.value = "${currentAssetPage.value + 1}|${currentMemory.value.assets.length}"; @@ -135,10 +188,18 @@ class MemoryPage extends HookConsumerWidget { ref.read(hapticFeedbackProvider.notifier).selectionClick(); currentAssetPage.value = otherIndex; updateProgressText(); + // Wait for page change animation to finish await Future.delayed(const Duration(milliseconds: 400)); // And then precache the next asset await precacheAsset(otherIndex + 1); + + final asset = currentMemory.value.assets[otherIndex]; + currentAsset.value = asset; + ref.read(currentAssetProvider.notifier).set(asset); + if (asset.isVideo || asset.isMotionPhoto) { + ref.read(videoPlaybackValueProvider.notifier).reset(); + } } /* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called @@ -235,19 +296,42 @@ class MemoryPage extends HookConsumerWidget { itemCount: memories[mIndex].assets.length, itemBuilder: (context, index) { final asset = memories[mIndex].assets[index]; - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () { - toNextAsset(index); - }, - child: Container( - color: Colors.black, - child: MemoryCard( - asset: asset, - title: memories[mIndex].title, - showTitle: index == 0, + return Stack( + children: [ + Container( + color: Colors.black, + child: MemoryCard( + asset: asset, + title: memories[mIndex].title, + showTitle: index == 0, + ), ), - ), + Positioned.fill( + child: Row( + children: [ + // Left side of the screen + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + toPreviousAsset(index); + }, + ), + ), + + // Right side of the screen + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + toNextAsset(index); + }, + ), + ), + ], + ), + ), + ], ); }, ), @@ -266,7 +350,7 @@ class MemoryPage extends HookConsumerWidget { ); }, shape: const CircleBorder(), - color: Colors.white.withOpacity(0.2), + color: Colors.white.withValues(alpha: 0.2), elevation: 0, child: const Icon( Icons.close_rounded, @@ -274,6 +358,16 @@ class MemoryPage extends HookConsumerWidget { ), ), ), + if (currentAsset.value != null && + currentAsset.value!.isVideo) + Positioned( + bottom: 24, + right: 32, + child: Icon( + Icons.videocam_outlined, + color: Colors.grey[200], + ), + ), ], ), ), diff --git a/mobile/lib/pages/photos/photos.page.dart b/mobile/lib/pages/photos/photos.page.dart index 9e15b0193e..b3bfa366f2 100644 --- a/mobile/lib/pages/photos/photos.page.dart +++ b/mobile/lib/pages/photos/photos.page.dart @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; +import 'package:immich_mobile/providers/timeline.provider.dart'; import 'package:immich_mobile/widgets/memories/memory_lane.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; @@ -83,11 +84,18 @@ class PhotosPage extends HookConsumerWidget { Future refreshAssets() async { final fullRefresh = refreshCount.value > 0; - await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh); + if (fullRefresh) { + Future.wait([ + ref.read(assetProvider.notifier).getAllAsset(clear: true), + ref.read(albumProvider.notifier).refreshRemoteAlbums(), + ]); + // refresh was forced: user requested another refresh within 2 seconds refreshCount.value = 0; } else { + await ref.read(assetProvider.notifier).getAllAsset(clear: false); + refreshCount.value++; // set counter back to 0 if user does not request refresh again Timer(const Duration(seconds: 4), () => refreshCount.value = 0); @@ -101,8 +109,8 @@ class PhotosPage extends HookConsumerWidget { ? const MemoryLane() : const SizedBox(), renderListProvider: timelineUsers.length > 1 - ? multiUserAssetsProvider(timelineUsers) - : assetsProvider(currentUser?.isarId), + ? multiUsersTimelineProvider(timelineUsers) + : singleUserTimelineProvider(currentUser?.isarId), buildLoadingIndicator: buildLoadingIndicator, onRefresh: refreshAssets, stackEnabled: true, diff --git a/mobile/lib/pages/search/all_videos.page.dart b/mobile/lib/pages/search/all_videos.page.dart index e96e060255..b7997313f3 100644 --- a/mobile/lib/pages/search/all_videos.page.dart +++ b/mobile/lib/pages/search/all_videos.page.dart @@ -2,7 +2,7 @@ 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/providers/search/all_video_assets.provider.dart'; +import 'package:immich_mobile/providers/timeline.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; @RoutePage() @@ -19,7 +19,7 @@ class AllVideosPage extends HookConsumerWidget { icon: const Icon(Icons.arrow_back_ios_rounded), ), ), - body: MultiselectGrid(renderListProvider: allVideoAssetsProvider), + body: MultiselectGrid(renderListProvider: allVideosTimelineProvider), ); } } diff --git a/mobile/lib/pages/search/map/map.page.dart b/mobile/lib/pages/search/map/map.page.dart index 52ce13f958..0e64759241 100644 --- a/mobile/lib/pages/search/map/map.page.dart +++ b/mobile/lib/pages/search/map/map.page.dart @@ -11,7 +11,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/latlngbounds_extension.dart'; import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart'; import 'package:immich_mobile/models/map/map_event.model.dart'; import 'package:immich_mobile/models/map/map_marker.model.dart'; @@ -39,7 +38,7 @@ class MapPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final mapController = useRef(null); + final mapController = useRef(null); final markers = useRef>([]); final markersInBounds = useRef>([]); final bottomSheetStreamController = useStreamController(); @@ -162,7 +161,7 @@ class MapPage extends HookConsumerWidget { } } - void onMapCreated(MaplibreMapController controller) async { + void onMapCreated(MapLibreMapController controller) async { mapController.value = controller; controller.addListener(() { if (controller.isCameraMoving && selectedMarker.value != null) { @@ -389,7 +388,7 @@ class _MapWithMarker extends StatelessWidget { child: Stack( children: [ style.widgetWhen( - onData: (style) => MaplibreMap( + onData: (style) => MapLibreMap( initialCameraPosition: const CameraPosition(target: LatLng(0, 0)), styleString: style, @@ -403,7 +402,7 @@ class _MapWithMarker extends StatelessWidget { tiltGesturesEnabled: false, dragEnabled: false, myLocationEnabled: false, - attributionButtonPosition: AttributionButtonPosition.TopRight, + attributionButtonPosition: AttributionButtonPosition.topRight, rotateGesturesEnabled: false, ), ), 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 487de69a1e..9d526d8080 100644 --- a/mobile/lib/pages/search/map/map_location_picker.page.dart +++ b/mobile/lib/pages/search/map/map_location_picker.page.dart @@ -24,7 +24,7 @@ class MapLocationPickerPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final selectedLatLng = useValueNotifier(initialLatLng); - final controller = useRef(null); + final controller = useRef(null); final marker = useRef(null); Future onStyleLoaded() async { @@ -74,7 +74,7 @@ class MapLocationPickerPage extends HookConsumerWidget { bottomRight: Radius.circular(40), ), ), - child: MaplibreMap( + child: MapLibreMap( initialCameraPosition: CameraPosition(target: initialLatLng, zoom: 12), styleString: style, diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index 32e73f5c24..464ac31728 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -5,6 +5,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/constants/enums.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; @@ -31,7 +32,8 @@ class SearchPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final isContextualSearch = useState(true); + final textSearchType = useState(TextSearchType.context); + final searchHintText = useState('contextual_search'.tr()); final textSearchController = useTextEditingController(); final filter = useState( SearchFilter( @@ -49,7 +51,7 @@ class SearchPage extends HookConsumerWidget { ), ); - final previousFilter = useState(filter.value); + final previousFilter = useState(null); final peopleCurrentFilterWidget = useState(null); final dateRangeCurrentFilterWidget = useState(null); @@ -60,19 +62,55 @@ class SearchPage extends HookConsumerWidget { final isSearching = useState(false); + SnackBar searchInfoSnackBar(String message) { + return SnackBar( + content: Text( + message, + style: context.textTheme.labelLarge, + ), + showCloseIcon: true, + behavior: SnackBarBehavior.fixed, + closeIconColor: context.colorScheme.onSurface, + ); + } + search() async { - if (prefilter == null && filter.value == previousFilter.value) return; + if (filter.value.isEmpty) { + return; + } + + if (prefilter == null && filter.value == previousFilter.value) { + return; + } isSearching.value = true; ref.watch(paginatedSearchProvider.notifier).clear(); - await ref.watch(paginatedSearchProvider.notifier).search(filter.value); + final hasResult = await ref + .watch(paginatedSearchProvider.notifier) + .search(filter.value); + + if (!hasResult) { + context.showSnackBar( + searchInfoSnackBar('search_no_result'.tr()), + ); + } + previousFilter.value = filter.value; isSearching.value = false; } loadMoreSearchResult() async { isSearching.value = true; - await ref.watch(paginatedSearchProvider.notifier).search(filter.value); + final hasResult = await ref + .watch(paginatedSearchProvider.notifier) + .search(filter.value); + + if (!hasResult) { + context.showSnackBar( + searchInfoSnackBar('search_no_more_result'.tr()), + ); + } + isSearching.value = false; } @@ -277,7 +315,6 @@ class SearchPage extends HookConsumerWidget { fieldEndHintText: 'end_date'.tr(), initialEntryMode: DatePickerEntryMode.calendar, keyboardType: TextInputType.text, - locale: context.locale, ); if (date == null) { @@ -443,37 +480,146 @@ class SearchPage extends HookConsumerWidget { } handleTextSubmitted(String value) { - if (isContextualSearch.value) { - filter.value = filter.value.copyWith( - filename: '', - context: value, - ); - } else { - filter.value = filter.value.copyWith( - filename: value, - context: '', - ); + switch (textSearchType.value) { + case TextSearchType.context: + filter.value = filter.value.copyWith( + filename: '', + context: value, + description: '', + ); + + break; + case TextSearchType.filename: + filter.value = filter.value.copyWith( + filename: value, + context: '', + description: '', + ); + + break; + case TextSearchType.description: + filter.value = filter.value.copyWith( + filename: '', + context: '', + description: value, + ); + break; } search(); } + IconData getSearchPrefixIcon() { + switch (textSearchType.value) { + case TextSearchType.context: + return Icons.image_search_rounded; + case TextSearchType.filename: + return Icons.abc_rounded; + case TextSearchType.description: + return Icons.text_snippet_outlined; + } + } + return Scaffold( resizeToAvoidBottomInset: true, appBar: AppBar( automaticallyImplyLeading: true, actions: [ Padding( - padding: const EdgeInsets.only(right: 14.0), - child: IconButton( - key: const Key('contextual_search_button'), - icon: isContextualSearch.value - ? const Icon(Icons.abc_rounded) - : const Icon(Icons.image_search_rounded), - onPressed: () { - isContextualSearch.value = !isContextualSearch.value; - textSearchController.clear(); + padding: const EdgeInsets.only(right: 16.0), + child: MenuAnchor( + style: MenuStyle( + elevation: const WidgetStatePropertyAll(1), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + ), + padding: const WidgetStatePropertyAll( + EdgeInsets.all(4), + ), + ), + builder: ( + BuildContext context, + MenuController controller, + Widget? child, + ) { + return IconButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + icon: const Icon(Icons.more_vert_rounded), + tooltip: 'Show text search menu', + ); }, + menuChildren: [ + MenuItemButton( + child: ListTile( + leading: const Icon(Icons.image_search_rounded), + title: Text( + 'search_filter_contextual'.tr(), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: textSearchType.value == TextSearchType.context + ? context.colorScheme.primary + : null, + ), + ), + selectedColor: context.colorScheme.primary, + selected: textSearchType.value == TextSearchType.context, + ), + onPressed: () { + textSearchType.value = TextSearchType.context; + searchHintText.value = 'contextual_search'.tr(); + }, + ), + MenuItemButton( + child: ListTile( + leading: const Icon(Icons.abc_rounded), + title: Text( + 'search_filter_filename'.tr(), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: textSearchType.value == TextSearchType.filename + ? context.colorScheme.primary + : null, + ), + ), + selectedColor: context.colorScheme.primary, + selected: textSearchType.value == TextSearchType.filename, + ), + onPressed: () { + textSearchType.value = TextSearchType.filename; + searchHintText.value = 'filename_search'.tr(); + }, + ), + MenuItemButton( + child: ListTile( + leading: const Icon(Icons.text_snippet_outlined), + title: Text( + 'search_filter_description'.tr(), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: + textSearchType.value == TextSearchType.description + ? context.colorScheme.primary + : null, + ), + ), + selectedColor: context.colorScheme.primary, + selected: + textSearchType.value == TextSearchType.description, + ), + onPressed: () { + textSearchType.value = TextSearchType.description; + searchHintText.value = 'description_search'.tr(); + }, + ), + ], ), ), ], @@ -486,9 +632,9 @@ class SearchPage extends HookConsumerWidget { borderRadius: BorderRadius.circular(24), gradient: LinearGradient( colors: [ - context.colorScheme.primary.withOpacity(0.075), - context.colorScheme.primary.withOpacity(0.09), - context.colorScheme.primary.withOpacity(0.075), + context.colorScheme.primary.withValues(alpha: 0.075), + context.colorScheme.primary.withValues(alpha: 0.09), + context.colorScheme.primary.withValues(alpha: 0.075), ], begin: Alignment.topLeft, end: Alignment.bottomRight, @@ -504,12 +650,10 @@ class SearchPage extends HookConsumerWidget { prefixIcon: prefilter != null ? null : Icon( - Icons.search_rounded, + getSearchPrefixIcon(), color: context.colorScheme.primary, ), - hintText: isContextualSearch.value - ? 'contextual_search'.tr() - : 'filename_search'.tr(), + hintText: searchHintText.value, hintStyle: context.textTheme.bodyLarge?.copyWith( color: context.themeData.colorScheme.onSurfaceSecondary, ), @@ -597,10 +741,15 @@ class SearchPage extends HookConsumerWidget { ), ), ), - SearchResultGrid( - onScrollEnd: loadMoreSearchResult, - isSearching: isSearching.value, - ), + if (isSearching.value) + const Expanded( + child: Center(child: CircularProgressIndicator.adaptive()), + ) + else + SearchResultGrid( + onScrollEnd: loadMoreSearchResult, + isSearching: isSearching.value, + ), ], ), ); diff --git a/mobile/lib/pages/share_intent/share_intent.page.dart b/mobile/lib/pages/share_intent/share_intent.page.dart index 56d093b761..d18fd15b6d 100644 --- a/mobile/lib/pages/share_intent/share_intent.page.dart +++ b/mobile/lib/pages/share_intent/share_intent.page.dart @@ -1,14 +1,14 @@ 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:flutter_hooks/flutter_hooks.dart' hide Store; 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/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'; -import 'package:immich_mobile/entities/store.entity.dart' as db_store; @RoutePage() class ShareIntentPage extends HookConsumerWidget { @@ -18,8 +18,7 @@ class ShareIntentPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final currentEndpoint = - db_store.Store.get(db_store.StoreKey.serverEndpoint); + final currentEndpoint = Store.get(StoreKey.serverEndpoint); final candidates = ref.watch(shareIntentUploadProvider); final isUploaded = useState(false); diff --git a/mobile/lib/providers/activity.provider.dart b/mobile/lib/providers/activity.provider.dart index 8ae218c817..0dcc99320b 100644 --- a/mobile/lib/providers/activity.provider.dart +++ b/mobile/lib/providers/activity.provider.dart @@ -5,6 +5,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'activity.provider.g.dart'; +// ignore: unintended_html_in_doc_comment /// Maintains the current list of all activities for @riverpod class AlbumActivity extends _$AlbumActivity { diff --git a/mobile/lib/providers/activity.provider.g.dart b/mobile/lib/providers/activity.provider.g.dart index 9c20a09793..af574b991a 100644 --- a/mobile/lib/providers/activity.provider.g.dart +++ b/mobile/lib/providers/activity.provider.g.dart @@ -187,6 +187,8 @@ class AlbumActivityProvider extends AutoDisposeAsyncNotifierProviderImpl< } } +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element mixin AlbumActivityRef on AutoDisposeAsyncNotifierProviderRef> { /// The parameter `albumId` of this provider. String get albumId; @@ -206,4 +208,4 @@ class _AlbumActivityProviderElement String? get assetId => (origin as AlbumActivityProvider).assetId; } // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mobile/lib/providers/activity_service.provider.dart b/mobile/lib/providers/activity_service.provider.dart index 6bd139c565..2d63e55354 100644 --- a/mobile/lib/providers/activity_service.provider.dart +++ b/mobile/lib/providers/activity_service.provider.dart @@ -1,3 +1,4 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/repositories/activity_api.repository.dart'; import 'package:immich_mobile/services/activity.service.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -5,5 +6,5 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'activity_service.provider.g.dart'; @riverpod -ActivityService activityService(ActivityServiceRef ref) => +ActivityService activityService(Ref ref) => ActivityService(ref.watch(activityApiRepositoryProvider)); diff --git a/mobile/lib/providers/activity_service.provider.g.dart b/mobile/lib/providers/activity_service.provider.g.dart index d42b2a39e4..2bf160c487 100644 --- a/mobile/lib/providers/activity_service.provider.g.dart +++ b/mobile/lib/providers/activity_service.provider.g.dart @@ -6,7 +6,7 @@ part of 'activity_service.provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$activityServiceHash() => r'23a3ee7db71676d2719daa64217a683cc5c7eab0'; +String _$activityServiceHash() => r'ce775779787588defe1e76406e09a9c109470310'; /// See also [activityService]. @ProviderFor(activityService) @@ -20,6 +20,8 @@ final activityServiceProvider = AutoDisposeProvider.internal( allTransitiveDependencies: null, ); +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element typedef ActivityServiceRef = AutoDisposeProviderRef; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mobile/lib/providers/activity_statistics.provider.dart b/mobile/lib/providers/activity_statistics.provider.dart index b1d2b4b987..c260a7a547 100644 --- a/mobile/lib/providers/activity_statistics.provider.dart +++ b/mobile/lib/providers/activity_statistics.provider.dart @@ -3,6 +3,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'activity_statistics.provider.g.dart'; +// ignore: unintended_html_in_doc_comment /// Maintains the current number of comments by @riverpod class ActivityStatistics extends _$ActivityStatistics { diff --git a/mobile/lib/providers/activity_statistics.provider.g.dart b/mobile/lib/providers/activity_statistics.provider.g.dart index 16a3c0e81b..d2de32c0aa 100644 --- a/mobile/lib/providers/activity_statistics.provider.g.dart +++ b/mobile/lib/providers/activity_statistics.provider.g.dart @@ -186,6 +186,8 @@ class ActivityStatisticsProvider } } +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element mixin ActivityStatisticsRef on AutoDisposeNotifierProviderRef { /// The parameter `albumId` of this provider. String get albumId; @@ -205,4 +207,4 @@ class _ActivityStatisticsProviderElement String? get assetId => (origin as ActivityStatisticsProvider).assetId; } // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mobile/lib/providers/album/album.provider.dart b/mobile/lib/providers/album/album.provider.dart index 8c06faaa6a..a2d7db68ec 100644 --- a/mobile/lib/providers/album/album.provider.dart +++ b/mobile/lib/providers/album/album.provider.dart @@ -5,46 +5,42 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/services/album.service.dart'; -import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/utils/renderlist_generator.dart'; -import 'package:isar/isar.dart'; final isRefreshingRemoteAlbumProvider = StateProvider((ref) => false); class AlbumNotifier extends StateNotifier> { - AlbumNotifier(this._albumService, this.db, this.ref) : super([]) { - final query = db.albums.filter().remoteIdIsNotNull(); - query.findAll().then((value) { + AlbumNotifier(this.albumService, this.ref) : super([]) { + albumService.getAllRemoteAlbums().then((value) { if (mounted) { state = value; } }); - _streamSub = query.watch().listen((data) => state = data); + + _streamSub = + albumService.watchRemoteAlbums().listen((data) => state = data); } - final AlbumService _albumService; - final Isar db; + final AlbumService albumService; final Ref ref; late final StreamSubscription> _streamSub; Future refreshRemoteAlbums() async { ref.read(isRefreshingRemoteAlbumProvider.notifier).state = true; - await _albumService.refreshRemoteAlbums(); + await albumService.refreshRemoteAlbums(); ref.read(isRefreshingRemoteAlbumProvider.notifier).state = false; } - Future refreshDeviceAlbums() => _albumService.refreshDeviceAlbums(); + Future refreshDeviceAlbums() => albumService.refreshDeviceAlbums(); - Future deleteAlbum(Album album) => _albumService.deleteAlbum(album); + Future deleteAlbum(Album album) => albumService.deleteAlbum(album); Future createAlbum( String albumTitle, Set assets, ) => - _albumService.createAlbum(albumTitle, assets, []); + albumService.createAlbum(albumTitle, assets, []); Future getAlbumByName( String albumName, { @@ -52,7 +48,7 @@ class AlbumNotifier extends StateNotifier> { bool? shared, bool? owner, }) => - _albumService.getAlbumByName( + albumService.getAlbumByName( albumName, remote: remote, shared: shared, @@ -74,7 +70,7 @@ class AlbumNotifier extends StateNotifier> { } Future leaveAlbum(Album album) async { - var res = await _albumService.leaveAlbum(album); + var res = await albumService.leaveAlbum(album); if (res) { await deleteAlbum(album); @@ -85,15 +81,15 @@ class AlbumNotifier extends StateNotifier> { } void searchAlbums(String searchTerm, QuickFilterMode filterMode) async { - state = await _albumService.search(searchTerm, filterMode); + state = await albumService.search(searchTerm, filterMode); } Future addUsers(Album album, List userIds) async { - await _albumService.addUsers(album, userIds); + await albumService.addUsers(album, userIds); } Future removeUser(Album album, User user) async { - final isRemoved = await _albumService.removeUser(album, user); + final isRemoved = await albumService.removeUser(album, user); if (isRemoved && album.sharedUsers.isEmpty) { state = state.where((element) => element.id != album.id).toList(); @@ -103,25 +99,25 @@ class AlbumNotifier extends StateNotifier> { } Future addAssets(Album album, Iterable assets) async { - await _albumService.addAssets(album, assets); + await albumService.addAssets(album, assets); } Future removeAsset(Album album, Iterable assets) async { - return await _albumService.removeAsset(album, assets); + return await albumService.removeAsset(album, assets); } Future setActivitystatus( Album album, bool enabled, ) { - return _albumService.setActivityStatus(album, enabled); + return albumService.setActivityStatus(album, enabled); } Future toggleSortOrder(Album album) { final order = album.sortOrder == SortOrder.asc ? SortOrder.desc : SortOrder.asc; - return _albumService.updateSortOrder(album, order); + return albumService.updateSortOrder(album, order); } @override @@ -135,57 +131,38 @@ final albumProvider = StateNotifierProvider.autoDispose>((ref) { return AlbumNotifier( ref.watch(albumServiceProvider), - ref.watch(dbProvider), ref, ); }); final albumWatcher = - StreamProvider.autoDispose.family((ref, albumId) async* { - final db = ref.watch(dbProvider); - final a = await db.albums.get(albumId); - if (a != null) yield a; - await for (final a in db.albums.watchObject(albumId, fireImmediately: true)) { - if (a != null) yield a; - } -}); - -final albumRenderlistProvider = - StreamProvider.autoDispose.family((ref, albumId) { - final album = ref.watch(albumWatcher(albumId)).value; + StreamProvider.autoDispose.family((ref, id) async* { + final albumService = ref.watch(albumServiceProvider); + final album = await albumService.getAlbumById(id); if (album != null) { - final query = album.assets.filter().isTrashedEqualTo(false); - if (album.sortOrder == SortOrder.asc) { - return renderListGeneratorWithGroupBy( - query.sortByFileCreatedAt(), - GroupAssetsBy.none, - ); - } else if (album.sortOrder == SortOrder.desc) { - return renderListGeneratorWithGroupBy( - query.sortByFileCreatedAtDesc(), - GroupAssetsBy.none, - ); + yield album; + } + + await for (final album in albumService.watchAlbum(id)) { + if (album != null) { + yield album; } } - - return const Stream.empty(); }); class LocalAlbumsNotifier extends StateNotifier> { - LocalAlbumsNotifier(this.db) : super([]) { - final query = db.albums.where().remoteIdIsNull(); - - query.findAll().then((value) { + LocalAlbumsNotifier(this.albumService) : super([]) { + albumService.getAllLocalAlbums().then((value) { if (mounted) { state = value; } }); - _streamSub = query.watch().listen((data) => state = data); + _streamSub = albumService.watchLocalAlbums().listen((data) => state = data); } - final Isar db; + final AlbumService albumService; late final StreamSubscription> _streamSub; @override @@ -197,5 +174,5 @@ class LocalAlbumsNotifier extends StateNotifier> { final localAlbumsProvider = StateNotifierProvider.autoDispose>((ref) { - return LocalAlbumsNotifier(ref.watch(dbProvider)); + return LocalAlbumsNotifier(ref.watch(albumServiceProvider)); }); diff --git a/mobile/lib/providers/album/album_sort_by_options.provider.g.dart b/mobile/lib/providers/album/album_sort_by_options.provider.g.dart index 9a05bb6c7d..ba20e7eb66 100644 --- a/mobile/lib/providers/album/album_sort_by_options.provider.g.dart +++ b/mobile/lib/providers/album/album_sort_by_options.provider.g.dart @@ -40,4 +40,4 @@ final albumSortOrderProvider = typedef _$AlbumSortOrder = AutoDisposeNotifier; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mobile/lib/providers/album/current_album.provider.g.dart b/mobile/lib/providers/album/current_album.provider.g.dart index 50e8854637..60ebe3e333 100644 --- a/mobile/lib/providers/album/current_album.provider.g.dart +++ b/mobile/lib/providers/album/current_album.provider.g.dart @@ -22,4 +22,4 @@ final currentAlbumProvider = typedef _$CurrentAlbum = AutoDisposeNotifier; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mobile/lib/providers/api.provider.dart b/mobile/lib/providers/api.provider.dart index 8e48324c92..a994dacf2f 100644 --- a/mobile/lib/providers/api.provider.dart +++ b/mobile/lib/providers/api.provider.dart @@ -1,7 +1,8 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'api.provider.g.dart'; @Riverpod(keepAlive: true) -ApiService apiService(ApiServiceRef ref) => ApiService(); +ApiService apiService(Ref ref) => ApiService(); diff --git a/mobile/lib/providers/api.provider.g.dart b/mobile/lib/providers/api.provider.g.dart index 421d554314..76ccb4ad6d 100644 --- a/mobile/lib/providers/api.provider.g.dart +++ b/mobile/lib/providers/api.provider.g.dart @@ -6,7 +6,7 @@ part of 'api.provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$apiServiceHash() => r'5b8beddb448316bdae5e3963ff77601653715729'; +String _$apiServiceHash() => r'93a7e3b4d3004741abc3061c4688239c3a72f9c4'; /// See also [apiService]. @ProviderFor(apiService) @@ -19,6 +19,8 @@ final apiServiceProvider = Provider.internal( allTransitiveDependencies: null, ); +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element typedef ApiServiceRef = ProviderRef; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index 780e22b818..ccd073ef07 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -1,20 +1,23 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/services/background.service.dart'; +import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/providers/asset.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; -import 'package:immich_mobile/providers/auth.provider.dart'; -import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; +import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/providers/notification_permission.provider.dart'; -import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; -import 'package:immich_mobile/services/immich_logger.service.dart'; +import 'package:immich_mobile/services/background.service.dart'; +import 'package:isar/isar.dart'; import 'package:permission_handler/permission_handler.dart'; enum AppLifeCycleEnum { @@ -112,11 +115,13 @@ class AppLifeCycleNotifier extends StateNotifier { _ref.read(websocketProvider.notifier).disconnect(); } - ImmichLogger().flush(); + LogService.I.flush(); } - void handleAppDetached() { + Future handleAppDetached() async { state = AppLifeCycleEnum.detached; + LogService.I.flush(); + await Isar.getInstance()?.close(); // no guarantee this is called at all _ref.read(manualUploadProvider.notifier).cancelBackup(); } diff --git a/mobile/lib/providers/app_settings.provider.dart b/mobile/lib/providers/app_settings.provider.dart index a598be7a1f..81c5c8e201 100644 --- a/mobile/lib/providers/app_settings.provider.dart +++ b/mobile/lib/providers/app_settings.provider.dart @@ -1,8 +1,8 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'app_settings.provider.g.dart'; @Riverpod(keepAlive: true) -AppSettingsService appSettingsService(AppSettingsServiceRef ref) => - AppSettingsService(); +AppSettingsService appSettingsService(Ref ref) => AppSettingsService(); diff --git a/mobile/lib/providers/app_settings.provider.g.dart b/mobile/lib/providers/app_settings.provider.g.dart index a9954382a7..88cab49c1b 100644 --- a/mobile/lib/providers/app_settings.provider.g.dart +++ b/mobile/lib/providers/app_settings.provider.g.dart @@ -7,7 +7,7 @@ part of 'app_settings.provider.dart'; // ************************************************************************** String _$appSettingsServiceHash() => - r'45ea609a91d250290431a7a08a14d16b37c7515d'; + r'3736e0d384ec7b1f896938589656dd6eb1552d60'; /// See also [appSettingsService]. @ProviderFor(appSettingsService) @@ -21,6 +21,8 @@ final appSettingsServiceProvider = Provider.internal( allTransitiveDependencies: null, ); +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element typedef AppSettingsServiceRef = ProviderRef; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mobile/lib/providers/archive.provider.dart b/mobile/lib/providers/archive.provider.dart deleted file mode 100644 index ba4937bd82..0000000000 --- a/mobile/lib/providers/archive.provider.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/utils/renderlist_generator.dart'; -import 'package:isar/isar.dart'; - -final archiveProvider = StreamProvider((ref) { - final user = ref.watch(currentUserProvider); - if (user == null) return const Stream.empty(); - final query = ref - .watch(dbProvider) - .assets - .where() - .ownerIdEqualToAnyChecksum(user.isarId) - .filter() - .isArchivedEqualTo(true) - .isTrashedEqualTo(false) - .sortByFileCreatedAtDesc(); - return renderListGenerator(query, ref); -}); diff --git a/mobile/lib/providers/asset.provider.dart b/mobile/lib/providers/asset.provider.dart index 9252de01bf..53fe0338ce 100644 --- a/mobile/lib/providers/asset.provider.dart +++ b/mobile/lib/providers/asset.provider.dart @@ -1,30 +1,37 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/locale_provider.dart'; -import 'package:immich_mobile/providers/memory.provider.dart'; -import 'package:immich_mobile/repositories/asset_media.repository.dart'; -import 'package:immich_mobile/services/album.service.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/services/asset.service.dart'; -import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/providers/memory.provider.dart'; +import 'package:immich_mobile/services/album.service.dart'; +import 'package:immich_mobile/services/asset.service.dart'; +import 'package:immich_mobile/services/etag.service.dart'; +import 'package:immich_mobile/services/exif.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; -import 'package:immich_mobile/utils/db.dart'; -import 'package:immich_mobile/utils/renderlist_generator.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; +final assetProvider = StateNotifierProvider((ref) { + return AssetNotifier( + ref.watch(assetServiceProvider), + ref.watch(albumServiceProvider), + ref.watch(userServiceProvider), + ref.watch(syncServiceProvider), + ref.watch(etagServiceProvider), + ref.watch(exifServiceProvider), + ref, + ); +}); + class AssetNotifier extends StateNotifier { final AssetService _assetService; final AlbumService _albumService; final UserService _userService; final SyncService _syncService; - final Isar _db; - final StateNotifierProviderRef _ref; + final ETagService _etagService; + final ExifService _exifService; + final Ref _ref; final log = Logger('AssetNotifier'); bool _getAllAssetInProgress = false; bool _deleteInProgress = false; @@ -34,7 +41,8 @@ class AssetNotifier extends StateNotifier { this._albumService, this._userService, this._syncService, - this._db, + this._etagService, + this._exifService, this._ref, ) : super(false); @@ -48,10 +56,14 @@ class AssetNotifier extends StateNotifier { _getAllAssetInProgress = true; state = true; if (clear) { - await clearAssetsAndAlbums(_db); + await clearAllAssets(); log.info("Manual refresh requested, cleared assets and albums from db"); } - final bool changedUsers = await _userService.refreshUsers(); + final users = await _userService.getUsersFromServer(); + bool changedUsers = false; + if (users != null) { + changedUsers = await _syncService.syncUsersFromServer(users); + } final bool newRemote = await _assetService.refreshRemoteAssets(); final bool newLocal = await _albumService.refreshDeviceAlbums(); debugPrint( @@ -68,8 +80,15 @@ class AssetNotifier extends StateNotifier { } } - Future clearAllAsset() { - return clearAssetsAndAlbums(_db); + Future clearAllAssets() async { + await Store.delete(StoreKey.assetETag); + await Future.wait([ + _assetService.clearTable(), + _exifService.clearTable(), + _albumService.clearTable(), + _userService.clearTable(), + _etagService.clearTable(), + ]); } Future onNewAssetUploaded(Asset newAsset) async { @@ -78,102 +97,43 @@ class AssetNotifier extends StateNotifier { await _syncService.syncNewAssetToDb(newAsset); } - Future deleteLocalOnlyAssets( - Iterable deleteAssets, { - bool onlyBackedUp = false, - }) async { + Future deleteLocalAssets(List assets) async { _deleteInProgress = true; state = true; try { - // Filter the assets based on the backed-up status - final assets = onlyBackedUp - ? deleteAssets.where((e) => e.storage == AssetState.merged) - : deleteAssets; - - if (assets.isEmpty) { - return false; // No assets to delete - } - - // Proceed with local deletion of the filtered assets - final localDeleted = await _deleteLocalAssets(assets); - - if (localDeleted.isNotEmpty) { - final localOnlyIds = assets - .where((e) => e.storage == AssetState.local) - .map((e) => e.id) - .toList(); - - // Update merged assets to remote-only - final mergedAssets = - assets.where((e) => e.storage == AssetState.merged).map((e) { - e.localId = null; - return e; - }).toList(); - - // Update the local database - await _db.writeTxn(() async { - if (mergedAssets.isNotEmpty) { - await _db.assets - .putAll(mergedAssets); // Use the filtered merged assets - } - await _db.exifInfos.deleteAll(localOnlyIds); - await _db.assets.deleteAll(localOnlyIds); - }); - - return true; - } + await _assetService.deleteLocalAssets(assets); + return true; + } catch (error) { + log.severe("Failed to delete local assets", error); + return false; } finally { _deleteInProgress = false; state = false; } - - return false; } - Future deleteRemoteOnlyAssets( + /// Delete remote asset only + /// + /// Default behavior is trashing the asset + Future deleteRemoteAssets( Iterable deleteAssets, { - bool force = false, + bool shouldDeletePermanently = false, }) async { _deleteInProgress = true; state = true; try { - final remoteDeleted = await _deleteRemoteAssets(deleteAssets, force); - if (remoteDeleted.isNotEmpty) { - final assetsToUpdate = force - - /// If force, only update merged only assets and remove remote assets - ? remoteDeleted - .where((e) => e.storage == AssetState.merged) - .map((e) { - e.remoteId = null; - return e; - }) - // If not force, trash everything - : remoteDeleted.where((e) => e.isRemote).map((e) { - e.isTrashed = true; - return e; - }); - - await _db.writeTxn(() async { - if (assetsToUpdate.isNotEmpty) { - await _db.assets.putAll(assetsToUpdate.toList()); - } - if (force) { - final remoteOnly = remoteDeleted - .where((e) => e.storage == AssetState.remote) - .map((e) => e.id) - .toList(); - await _db.exifInfos.deleteAll(remoteOnly); - await _db.assets.deleteAll(remoteOnly); - } - }); - return true; - } + await _assetService.deleteRemoteAssets( + deleteAssets, + shouldDeletePermanently: shouldDeletePermanently, + ); + return true; + } catch (error) { + log.severe("Failed to delete remote assets", error); + return false; } finally { _deleteInProgress = false; state = false; } - return false; } Future deleteAssets( @@ -183,111 +143,18 @@ class AssetNotifier extends StateNotifier { _deleteInProgress = true; state = true; try { - final hasLocal = deleteAssets.any((a) => a.storage != AssetState.remote); - final localDeleted = await _deleteLocalAssets(deleteAssets); - final remoteDeleted = (hasLocal && localDeleted.isNotEmpty) || !hasLocal - ? await _deleteRemoteAssets(deleteAssets, force) - : []; - if (localDeleted.isNotEmpty || remoteDeleted.isNotEmpty) { - final dbIds = []; - final dbUpdates = []; - - // Local assets are removed - if (localDeleted.isNotEmpty) { - // Permanently remove local only assets from isar - dbIds.addAll( - deleteAssets - .where((a) => a.storage == AssetState.local) - .map((e) => e.id), - ); - - if (remoteDeleted.any((e) => e.isLocal)) { - // Force delete: Add all local assets including merged assets - if (force) { - dbIds.addAll(remoteDeleted.map((e) => e.id)); - // Soft delete: Remove local Id from asset and trash it - } else { - dbUpdates.addAll( - remoteDeleted.map((e) { - e.localId = null; - e.isTrashed = true; - return e; - }), - ); - } - } - } - - // Handle remote deletion - if (remoteDeleted.isNotEmpty) { - if (force) { - // Remove remote only assets - dbIds.addAll( - deleteAssets - .where((a) => a.storage == AssetState.remote) - .map((e) => e.id), - ); - // Local assets are not removed and there are merged assets - final hasLocal = remoteDeleted.any((e) => e.isLocal); - if (localDeleted.isEmpty && hasLocal) { - // Remove remote Id from local assets - dbUpdates.addAll( - remoteDeleted.map((e) { - e.remoteId = null; - // Remove from trashed if remote asset is removed - e.isTrashed = false; - return e; - }), - ); - } - } else { - dbUpdates.addAll( - remoteDeleted.map((e) { - e.isTrashed = true; - return e; - }), - ); - } - } - - await _db.writeTxn(() async { - await _db.assets.putAll(dbUpdates); - await _db.exifInfos.deleteAll(dbIds); - await _db.assets.deleteAll(dbIds); - }); - return true; - } + await _assetService.deleteAssets( + deleteAssets, + shouldDeletePermanently: force, + ); + return true; + } catch (error) { + log.severe("Failed to delete assets", error); + return false; } finally { _deleteInProgress = false; state = false; } - return false; - } - - Future> _deleteLocalAssets( - Iterable assetsToDelete, - ) async { - final List local = - assetsToDelete.where((a) => a.isLocal).map((a) => a.localId!).toList(); - // Delete asset from device - if (local.isNotEmpty) { - try { - return await _ref.read(assetMediaRepositoryProvider).deleteAll(local); - } catch (e, stack) { - log.severe("Failed to delete asset from device", e, stack); - } - } - return []; - } - - Future> _deleteRemoteAssets( - Iterable assetsToDelete, - bool? force, - ) async { - final Iterable remote = assetsToDelete.where((e) => e.isRemote); - - final isSuccess = await _assetService.deleteAssets(remote, force: force); - return isSuccess ? remote.toList() : []; } Future toggleFavorite(List assets, [bool? status]) { @@ -301,87 +168,20 @@ class AssetNotifier extends StateNotifier { } } -final assetProvider = StateNotifierProvider((ref) { - return AssetNotifier( - ref.watch(assetServiceProvider), - ref.watch(albumServiceProvider), - ref.watch(userServiceProvider), - ref.watch(syncServiceProvider), - ref.watch(dbProvider), - ref, - ); -}); - final assetDetailProvider = StreamProvider.autoDispose.family((ref, asset) async* { - yield await ref.watch(assetServiceProvider).loadExif(asset); - final db = ref.watch(dbProvider); - await for (final a in db.assets.watchObject(asset.id)) { - if (a != null) { - yield await ref.watch(assetServiceProvider).loadExif(a); + final assetService = ref.watch(assetServiceProvider); + yield await assetService.loadExif(asset); + + await for (final asset in assetService.watchAsset(asset.id)) { + if (asset != null) { + yield await ref.watch(assetServiceProvider).loadExif(asset); } } }); final assetWatcher = StreamProvider.autoDispose.family((ref, asset) { - final db = ref.watch(dbProvider); - return db.assets.watchObject(asset.id, fireImmediately: true); + final assetService = ref.watch(assetServiceProvider); + return assetService.watchAsset(asset.id, fireImmediately: true); }); - -final assetsProvider = StreamProvider.family( - (ref, userId) { - if (userId == null) return const Stream.empty(); - ref.watch(localeProvider); - final query = _commonFilterAndSort( - _assets(ref).where().ownerIdEqualToAnyChecksum(userId), - ); - return renderListGenerator(query, ref); - }, - dependencies: [localeProvider], -); - -final multiUserAssetsProvider = StreamProvider.family>( - (ref, userIds) { - if (userIds.isEmpty) return const Stream.empty(); - ref.watch(localeProvider); - final query = _commonFilterAndSort( - _assets(ref) - .where() - .anyOf(userIds, (q, u) => q.ownerIdEqualToAnyChecksum(u)), - ); - return renderListGenerator(query, ref); - }, - dependencies: [localeProvider], -); - -QueryBuilder? getRemoteAssetQuery(WidgetRef ref) { - final userId = ref.watch(currentUserProvider)?.isarId; - if (userId == null) { - return null; - } - return ref - .watch(dbProvider) - .assets - .where() - .remoteIdIsNotNull() - .filter() - .ownerIdEqualTo(userId) - .isTrashedEqualTo(false) - .stackPrimaryAssetIdIsNull() - .sortByFileCreatedAtDesc(); -} - -IsarCollection _assets(StreamProviderRef ref) => - ref.watch(dbProvider).assets; - -QueryBuilder _commonFilterAndSort( - QueryBuilder query, -) { - return query - .filter() - .isArchivedEqualTo(false) - .isTrashedEqualTo(false) - .stackPrimaryAssetIdIsNull() - .sortByFileCreatedAtDesc(); -} diff --git a/mobile/lib/providers/asset_viewer/asset_people.provider.g.dart b/mobile/lib/providers/asset_viewer/asset_people.provider.g.dart index df6ee779cc..ebe8a14186 100644 --- a/mobile/lib/providers/asset_viewer/asset_people.provider.g.dart +++ b/mobile/lib/providers/asset_viewer/asset_people.provider.g.dart @@ -171,6 +171,8 @@ class AssetPeopleNotifierProvider extends AutoDisposeAsyncNotifierProviderImpl< } } +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element mixin AssetPeopleNotifierRef on AutoDisposeAsyncNotifierProviderRef> { /// The parameter `asset` of this provider. @@ -186,4 +188,4 @@ class _AssetPeopleNotifierProviderElement Asset get asset => (origin as AssetPeopleNotifierProvider).asset; } // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mobile/lib/providers/asset_viewer/asset_stack.provider.dart b/mobile/lib/providers/asset_viewer/asset_stack.provider.dart index 407aef1610..0edefde526 100644 --- a/mobile/lib/providers/asset_viewer/asset_stack.provider.dart +++ b/mobile/lib/providers/asset_viewer/asset_stack.provider.dart @@ -1,16 +1,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:isar/isar.dart'; +import 'package:immich_mobile/services/asset.service.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'asset_stack.provider.g.dart'; class AssetStackNotifier extends StateNotifier> { + final AssetService assetService; final String _stackId; - final Ref _ref; - AssetStackNotifier(this._stackId, this._ref) : super([]) { + AssetStackNotifier(this.assetService, this._stackId) : super([]) { _fetchStack(_stackId); } @@ -19,7 +18,7 @@ class AssetStackNotifier extends StateNotifier> { return; } - final stack = await _ref.read(assetStackProvider(stackId).future); + final stack = await assetService.getStackAssets(stackId); if (stack.isNotEmpty) { state = stack; } @@ -35,25 +34,11 @@ class AssetStackNotifier extends StateNotifier> { final assetStackStateProvider = StateNotifierProvider.autoDispose .family, String>( - (ref, stackId) => AssetStackNotifier(stackId, ref), + (ref, stackId) => + AssetStackNotifier(ref.watch(assetServiceProvider), stackId), ); -final assetStackProvider = - FutureProvider.autoDispose.family, String>((ref, stackId) { - return ref - .watch(dbProvider) - .assets - .filter() - .isArchivedEqualTo(false) - .isTrashedEqualTo(false) - .stackIdEqualTo(stackId) - // orders primary asset first as its ID is null - .sortByStackPrimaryAssetId() - .thenByFileCreatedAtDesc() - .findAll(); -}); - @riverpod -int assetStackIndex(AssetStackIndexRef ref, Asset asset) { +int assetStackIndex(Ref ref, Asset asset) { return -1; } diff --git a/mobile/lib/providers/asset_viewer/asset_stack.provider.g.dart b/mobile/lib/providers/asset_viewer/asset_stack.provider.g.dart index 142e46d322..5d4051b285 100644 --- a/mobile/lib/providers/asset_viewer/asset_stack.provider.g.dart +++ b/mobile/lib/providers/asset_viewer/asset_stack.provider.g.dart @@ -6,7 +6,7 @@ part of 'asset_stack.provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$assetStackIndexHash() => r'0f2df55e929767c8c698bd432b5e6e351d000a16'; +String _$assetStackIndexHash() => r'38b4b0116e3e4592620b118ae01cf89b77da9cfe'; /// Copied from Dart SDK class _SystemHash { @@ -142,6 +142,8 @@ class AssetStackIndexProvider extends AutoDisposeProvider { } } +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element mixin AssetStackIndexRef on AutoDisposeProviderRef { /// The parameter `asset` of this provider. Asset get asset; @@ -155,4 +157,4 @@ class _AssetStackIndexProviderElement extends AutoDisposeProviderElement Asset get asset => (origin as AssetStackIndexProvider).asset; } // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mobile/lib/providers/asset_viewer/current_asset.provider.g.dart b/mobile/lib/providers/asset_viewer/current_asset.provider.g.dart index 96628dab58..53b02c2ace 100644 --- a/mobile/lib/providers/asset_viewer/current_asset.provider.g.dart +++ b/mobile/lib/providers/asset_viewer/current_asset.provider.g.dart @@ -22,4 +22,4 @@ final currentAssetProvider = typedef _$CurrentAsset = AutoDisposeNotifier; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mobile/lib/providers/asset_viewer/download.provider.dart b/mobile/lib/providers/asset_viewer/download.provider.dart index 68b120c38a..d699c7c763 100644 --- a/mobile/lib/providers/asset_viewer/download.provider.dart +++ b/mobile/lib/providers/asset_viewer/download.provider.dart @@ -104,7 +104,7 @@ class DownloadStateNotifier extends StateNotifier { } void _taskProgressCallback(TaskProgressUpdate update) { - // Ignore if the task is cancled or completed + // Ignore if the task is canceled or completed if (update.progress == -2 || update.progress == -1) { return; } diff --git a/mobile/lib/providers/asset_viewer/render_list.provider.dart b/mobile/lib/providers/asset_viewer/render_list.provider.dart deleted file mode 100644 index a0b3bba210..0000000000 --- a/mobile/lib/providers/asset_viewer/render_list.provider.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; -import 'package:immich_mobile/providers/app_settings.provider.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/utils/renderlist_generator.dart'; -import 'package:isar/isar.dart'; - -final renderListProvider = - FutureProvider.family>((ref, assets) { - final settings = ref.watch(appSettingsServiceProvider); - - return RenderList.fromAssets( - assets, - GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)], - ); -}); - -final renderListProviderWithGrouping = - FutureProvider.family, GroupAssetsBy?)>( - (ref, args) { - final settings = ref.watch(appSettingsServiceProvider); - final grouping = args.$2 ?? - GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)]; - return RenderList.fromAssets(args.$1, grouping); -}); - -final renderListQueryProvider = StreamProvider.family?>( - (ref, query) => - query == null ? const Stream.empty() : renderListGenerator(query, ref), -); diff --git a/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart b/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart index a5a42ec796..ed2c485b13 100644 --- a/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart +++ b/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart @@ -117,7 +117,7 @@ class ShareIntentUploadStateNotifier } void _taskProgressCallback(TaskProgressUpdate update) { - // Ignore if the task is cancled or completed + // Ignore if the task is canceled or completed if (update.progress == downloadFailed || update.progress == downloadCompleted) { return; diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index a23ffd3d68..e2939e89ce 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -1,10 +1,11 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_udid/flutter_udid.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'; -import 'package:immich_mobile/models/auth/login_response.model.dart'; -import 'package:immich_mobile/models/auth/auth_state.model.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/models/auth/auth_state.model.dart'; +import 'package:immich_mobile/models/auth/login_response.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/auth.service.dart'; @@ -46,7 +47,7 @@ class AuthNotifier extends StateNotifier { } /// Validating the url is the alternative connecting server url without - /// saving the infomation to the local database + /// saving the information to the local database Future validateAuxilaryServerUrl(String url) async { try { final validEndpoint = await _apiService.resolveEndpoint(url); @@ -98,7 +99,7 @@ class AuthNotifier extends StateNotifier { Future saveAuthInfo({ required String accessToken, }) async { - _apiService.setAccessToken(accessToken); + await _apiService.setAccessToken(accessToken); // Get the deviceid from the store if it exists, otherwise generate a new one String deviceId = @@ -141,13 +142,13 @@ class AuthNotifier extends StateNotifier { // If the user information is successfully retrieved, update the store // Due to the flow of the code, this will always happen on first login if (userResponse != null) { - Store.put(StoreKey.deviceId, deviceId); - Store.put(StoreKey.deviceIdHash, fastHash(deviceId)); - Store.put( + await Store.put(StoreKey.deviceId, deviceId); + await Store.put(StoreKey.deviceIdHash, fastHash(deviceId)); + await Store.put( StoreKey.currentUser, User.fromUserDto(userResponse, userPreferences), ); - Store.put(StoreKey.accessToken, accessToken); + await Store.put(StoreKey.accessToken, accessToken); user = User.fromUserDto(userResponse, userPreferences); } else { @@ -173,12 +174,12 @@ class AuthNotifier extends StateNotifier { return true; } - Future saveWifiName(String wifiName) { - return Store.put(StoreKey.preferredWifiName, wifiName); + Future saveWifiName(String wifiName) async { + await Store.put(StoreKey.preferredWifiName, wifiName); } - Future saveLocalEndpoint(String url) { - return Store.put(StoreKey.localEndpoint, url); + Future saveLocalEndpoint(String url) async { + await Store.put(StoreKey.localEndpoint, url); } String? getSavedWifiName() { diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index aab367485c..a4f4fea45c 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -5,38 +5,52 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; -import 'package:immich_mobile/interfaces/album_media.interface.dart'; -import 'package:immich_mobile/interfaces/backup.interface.dart'; -import 'package:immich_mobile/interfaces/file_media.interface.dart'; -import 'package:immich_mobile/models/backup/available_album.model.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/interfaces/album_media.interface.dart'; +import 'package:immich_mobile/interfaces/backup_album.interface.dart'; +import 'package:immich_mobile/interfaces/file_media.interface.dart'; +import 'package:immich_mobile/models/auth/auth_state.model.dart'; +import 'package:immich_mobile/models/backup/available_album.model.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; +import 'package:immich_mobile/models/server_info/server_disk_info.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/error_backup_list.provider.dart'; +import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; -import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/backup.service.dart'; -import 'package:immich_mobile/models/auth/auth_state.model.dart'; -import 'package:immich_mobile/providers/auth.provider.dart'; -import 'package:immich_mobile/providers/gallery_permission.provider.dart'; -import 'package:immich_mobile/models/server_info/server_disk_info.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/services/backup_album.service.dart'; import 'package:immich_mobile/services/server_info.service.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/diff.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; +final backupProvider = + StateNotifierProvider((ref) { + return BackupNotifier( + ref.watch(backupServiceProvider), + ref.watch(serverInfoServiceProvider), + ref.watch(authProvider), + ref.watch(backgroundServiceProvider), + ref.watch(galleryPermissionNotifier.notifier), + ref.watch(albumMediaRepositoryProvider), + ref.watch(fileMediaRepositoryProvider), + ref.watch(backupAlbumServiceProvider), + ref, + ); +}); + class BackupNotifier extends StateNotifier { BackupNotifier( this._backupService, @@ -44,10 +58,9 @@ class BackupNotifier extends StateNotifier { this._authState, this._backgroundService, this._galleryPermissionNotifier, - this._db, this._albumMediaRepository, this._fileMediaRepository, - this._backupRepository, + this._backupAlbumService, this.ref, ) : super( BackUpState( @@ -95,10 +108,9 @@ class BackupNotifier extends StateNotifier { final AuthState _authState; final BackgroundService _backgroundService; final GalleryPermissionNotifier _galleryPermissionNotifier; - final Isar _db; final IAlbumMediaRepository _albumMediaRepository; final IFileMediaRepository _fileMediaRepository; - final IBackupRepository _backupRepository; + final BackupAlbumService _backupAlbumService; final Ref ref; /// @@ -259,9 +271,9 @@ class BackupNotifier extends StateNotifier { state = state.copyWith(availableAlbums: availableAlbums); final List excludedBackupAlbums = - await _backupRepository.getAllBySelection(BackupSelection.exclude); + await _backupAlbumService.getAllBySelection(BackupSelection.exclude); final List selectedBackupAlbums = - await _backupRepository.getAllBySelection(BackupSelection.select); + await _backupAlbumService.getAllBySelection(BackupSelection.select); final Set selectedAlbums = {}; for (final BackupAlbum ba in selectedBackupAlbums) { @@ -438,7 +450,7 @@ class BackupNotifier extends StateNotifier { } /// Save user selection of selected albums and excluded albums to database - Future _updatePersistentAlbumsSelection() { + Future _updatePersistentAlbumsSelection() async { final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); final selected = state.selectedBackupAlbums.map( (e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.select), @@ -446,29 +458,30 @@ class BackupNotifier extends StateNotifier { final excluded = state.excludedBackupAlbums.map( (e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.exclude), ); - final backupAlbums = selected.followedBy(excluded).toList(); - backupAlbums.sortBy((e) => e.id); - return _db.writeTxn(() async { - final dbAlbums = await _db.backupAlbums.where().sortById().findAll(); - final List toDelete = []; - final List toUpsert = []; - // stores the most recent `lastBackup` per album but always keeps the `selection` the user just made - diffSortedListsSync( - dbAlbums, - backupAlbums, - compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id), - both: (BackupAlbum a, BackupAlbum b) { - b.lastBackup = - a.lastBackup.isAfter(b.lastBackup) ? a.lastBackup : b.lastBackup; - toUpsert.add(b); - return true; - }, - onlyFirst: (BackupAlbum a) => toDelete.add(a.isarId), - onlySecond: (BackupAlbum b) => toUpsert.add(b), - ); - await _db.backupAlbums.deleteAll(toDelete); - await _db.backupAlbums.putAll(toUpsert); - }); + final candidates = selected.followedBy(excluded).toList(); + candidates.sortBy((e) => e.id); + + final savedBackupAlbums = + await _backupAlbumService.getAll(sort: BackupAlbumSort.id); + final List toDelete = []; + final List toUpsert = []; + + diffSortedListsSync( + savedBackupAlbums, + candidates, + compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id), + both: (BackupAlbum a, BackupAlbum b) { + b.lastBackup = + a.lastBackup.isAfter(b.lastBackup) ? a.lastBackup : b.lastBackup; + toUpsert.add(b); + return true; + }, + onlyFirst: (BackupAlbum a) => toDelete.add(a.isarId), + onlySecond: (BackupAlbum b) => toUpsert.add(b), + ); + + await _backupAlbumService.deleteAll(toDelete); + await _backupAlbumService.updateAll(toUpsert); } /// Invoke backup process @@ -685,14 +698,10 @@ class BackupNotifier extends StateNotifier { } Future resumeBackup() async { - final List selectedBackupAlbums = await _db.backupAlbums - .filter() - .selectionEqualTo(BackupSelection.select) - .findAll(); - final List excludedBackupAlbums = await _db.backupAlbums - .filter() - .selectionEqualTo(BackupSelection.exclude) - .findAll(); + final List selectedBackupAlbums = + await _backupAlbumService.getAllBySelection(BackupSelection.select); + final List excludedBackupAlbums = + await _backupAlbumService.getAllBySelection(BackupSelection.exclude); Set selectedAlbums = state.selectedBackupAlbums; Set excludedAlbums = state.excludedBackupAlbums; if (selectedAlbums.isNotEmpty) { @@ -755,23 +764,8 @@ class BackupNotifier extends StateNotifier { } BackUpProgressEnum get backupProgress => state.backupProgress; + void updateBackupProgress(BackUpProgressEnum backupProgress) { state = state.copyWith(backupProgress: backupProgress); } } - -final backupProvider = - StateNotifierProvider((ref) { - return BackupNotifier( - ref.watch(backupServiceProvider), - ref.watch(serverInfoServiceProvider), - ref.watch(authProvider), - ref.watch(backgroundServiceProvider), - ref.watch(galleryPermissionNotifier.notifier), - ref.watch(dbProvider), - ref.watch(albumMediaRepositoryProvider), - ref.watch(fileMediaRepositoryProvider), - ref.watch(backupRepositoryProvider), - ref, - ); -}); diff --git a/mobile/lib/providers/backup/backup_verification.provider.g.dart b/mobile/lib/providers/backup/backup_verification.provider.g.dart index 9b52698847..bae3ec366b 100644 --- a/mobile/lib/providers/backup/backup_verification.provider.g.dart +++ b/mobile/lib/providers/backup/backup_verification.provider.g.dart @@ -24,4 +24,4 @@ final backupVerificationProvider = typedef _$BackupVerification = AutoDisposeNotifier; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mobile/lib/providers/backup/manual_upload.provider.dart b/mobile/lib/providers/backup/manual_upload.provider.dart index 192126f085..6eaf0f7226 100644 --- a/mobile/lib/providers/backup/manual_upload.provider.dart +++ b/mobile/lib/providers/backup/manual_upload.provider.dart @@ -9,7 +9,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; -import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; @@ -24,6 +23,7 @@ import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; +import 'package:immich_mobile/services/backup_album.service.dart'; import 'package:immich_mobile/services/local_notification.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; @@ -37,7 +37,7 @@ final manualUploadProvider = ref.watch(localNotificationService), ref.watch(backupProvider.notifier), ref.watch(backupServiceProvider), - ref.watch(backupRepositoryProvider), + ref.watch(backupAlbumServiceProvider), ref, ); }); @@ -47,14 +47,14 @@ class ManualUploadNotifier extends StateNotifier { final LocalNotificationService _localNotificationService; final BackupNotifier _backupProvider; final BackupService _backupService; - final BackupRepository _backupRepository; + final BackupAlbumService _backupAlbumService; final Ref ref; ManualUploadNotifier( this._localNotificationService, this._backupProvider, this._backupService, - this._backupRepository, + this._backupAlbumService, this.ref, ) : super( ManualUploadState( @@ -210,9 +210,9 @@ class ManualUploadNotifier extends StateNotifier { } final selectedBackupAlbums = - await _backupRepository.getAllBySelection(BackupSelection.select); - final excludedBackupAlbums = - await _backupRepository.getAllBySelection(BackupSelection.exclude); + await _backupAlbumService.getAllBySelection(BackupSelection.select); + final excludedBackupAlbums = await _backupAlbumService + .getAllBySelection(BackupSelection.exclude); // Get candidates from selected albums and excluded albums Set candidates = diff --git a/mobile/lib/providers/favorite.provider.dart b/mobile/lib/providers/favorite.provider.dart deleted file mode 100644 index 340fd01080..0000000000 --- a/mobile/lib/providers/favorite.provider.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/utils/renderlist_generator.dart'; -import 'package:isar/isar.dart'; - -final favoriteAssetsProvider = StreamProvider((ref) { - final user = ref.watch(currentUserProvider); - if (user == null) return const Stream.empty(); - final query = ref - .watch(dbProvider) - .assets - .where() - .ownerIdEqualToAnyChecksum(user.isarId) - .filter() - .isFavoriteEqualTo(true) - .isTrashedEqualTo(false) - .sortByFileCreatedAtDesc(); - return renderListGenerator(query, ref); -}); diff --git a/mobile/lib/providers/folder.provider.dart b/mobile/lib/providers/folder.provider.dart new file mode 100644 index 0000000000..810c2cea73 --- /dev/null +++ b/mobile/lib/providers/folder.provider.dart @@ -0,0 +1,62 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/models/folder/root_folder.model.dart'; +import 'package:immich_mobile/services/folder.service.dart'; +import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; +import 'package:logging/logging.dart'; + +class FolderStructureNotifier extends StateNotifier> { + final FolderService _folderService; + final Logger _log = Logger("FolderStructureNotifier"); + + FolderStructureNotifier(this._folderService) : super(const AsyncLoading()); + + Future fetchFolders(SortOrder order) async { + try { + final folders = await _folderService.getFolderStructure(order); + state = AsyncData(folders); + } catch (e, stack) { + _log.severe("Failed to build folder structure", e, stack); + state = AsyncError(e, stack); + } + } +} + +final folderStructureProvider = + StateNotifierProvider>( + (ref) { + return FolderStructureNotifier( + ref.watch(folderServiceProvider), + ); +}); + +class FolderRenderListNotifier extends StateNotifier> { + final FolderService _folderService; + final RootFolder _folder; + final Logger _log = Logger("FolderAssetsNotifier"); + + FolderRenderListNotifier(this._folderService, this._folder) + : super(const AsyncLoading()); + + Future fetchAssets(SortOrder order) async { + try { + final assets = await _folderService.getFolderAssets(_folder, order); + final renderList = + await RenderList.fromAssets(assets, GroupAssetsBy.none); + state = AsyncData(renderList); + } catch (e, stack) { + _log.severe("Failed to fetch folder assets", e, stack); + state = AsyncError(e, stack); + } + } +} + +final folderRenderListProvider = StateNotifierProvider.family< + FolderRenderListNotifier, + AsyncValue, + RootFolder>((ref, folder) { + return FolderRenderListNotifier( + ref.watch(folderServiceProvider), + folder, + ); +}); diff --git a/mobile/lib/providers/gallery_permission.provider.dart b/mobile/lib/providers/gallery_permission.provider.dart index 8077ca99fe..07d9cca591 100644 --- a/mobile/lib/providers/gallery_permission.provider.dart +++ b/mobile/lib/providers/gallery_permission.provider.dart @@ -6,7 +6,7 @@ import 'package:permission_handler/permission_handler.dart'; class GalleryPermissionNotifier extends StateNotifier { GalleryPermissionNotifier() - : super(PermissionStatus.denied) // Denied is the intitial state + : super(PermissionStatus.denied) // Denied is the initial state { // Sets the initial state getGalleryPermissionStatus(); diff --git a/mobile/lib/providers/immich_logo_provider.dart b/mobile/lib/providers/immich_logo_provider.dart index c5c65fcfe0..a52aba5f9e 100644 --- a/mobile/lib/providers/immich_logo_provider.dart +++ b/mobile/lib/providers/immich_logo_provider.dart @@ -1,12 +1,13 @@ import 'dart:convert'; import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'immich_logo_provider.g.dart'; @riverpod -Future immichLogo(ImmichLogoRef ref) async { +Future immichLogo(Ref ref) async { final json = await rootBundle.loadString('assets/immich-logo.json'); final j = jsonDecode(json); return base64Decode(j['content']); diff --git a/mobile/lib/providers/immich_logo_provider.g.dart b/mobile/lib/providers/immich_logo_provider.g.dart index 1a95814e35..0889e60fda 100644 --- a/mobile/lib/providers/immich_logo_provider.g.dart +++ b/mobile/lib/providers/immich_logo_provider.g.dart @@ -6,7 +6,7 @@ part of 'immich_logo_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$immichLogoHash() => r'040cc44fae3339e0f40a091fb3b2f2abe9f83acd'; +String _$immichLogoHash() => r'6f23d217c44279537b7edee1ca80ebf47f69a4d0'; /// See also [immichLogo]. @ProviderFor(immichLogo) @@ -19,6 +19,8 @@ final immichLogoProvider = AutoDisposeFutureProvider.internal( allTransitiveDependencies: null, ); +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element typedef ImmichLogoRef = AutoDisposeFutureProviderRef; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mobile/lib/providers/infrastructure/db.provider.dart b/mobile/lib/providers/infrastructure/db.provider.dart new file mode 100644 index 0000000000..84010b3b96 --- /dev/null +++ b/mobile/lib/providers/infrastructure/db.provider.dart @@ -0,0 +1,8 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:isar/isar.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'db.provider.g.dart'; + +@Riverpod(keepAlive: true) +Isar isar(Ref ref) => throw UnimplementedError('isar'); diff --git a/mobile/lib/providers/infrastructure/db.provider.g.dart b/mobile/lib/providers/infrastructure/db.provider.g.dart new file mode 100644 index 0000000000..33b330192f --- /dev/null +++ b/mobile/lib/providers/infrastructure/db.provider.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'db.provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$isarHash() => r'69d3a06aa7e69a4381478e03f7956eb07d7f7feb'; + +/// See also [isar]. +@ProviderFor(isar) +final isarProvider = Provider.internal( + isar, + name: r'isarProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$isarHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef IsarRef = ProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mobile/lib/providers/infrastructure/exif.provider.dart b/mobile/lib/providers/infrastructure/exif.provider.dart new file mode 100644 index 0000000000..ecb67dd2fe --- /dev/null +++ b/mobile/lib/providers/infrastructure/exif.provider.dart @@ -0,0 +1,11 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/interfaces/exif.interface.dart'; +import 'package:immich_mobile/infrastructure/repositories/exif.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'exif.provider.g.dart'; + +@Riverpod(keepAlive: true) +IExifInfoRepository exifRepository(Ref ref) => + IsarExifRepository(ref.watch(isarProvider)); diff --git a/mobile/lib/providers/infrastructure/exif.provider.g.dart b/mobile/lib/providers/infrastructure/exif.provider.g.dart new file mode 100644 index 0000000000..053abf18cc --- /dev/null +++ b/mobile/lib/providers/infrastructure/exif.provider.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'exif.provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$exifRepositoryHash() => r'f0abe778ed61fbb257001fdf2ac6e17814011fee'; + +/// See also [exifRepository]. +@ProviderFor(exifRepository) +final exifRepositoryProvider = Provider.internal( + exifRepository, + name: r'exifRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$exifRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef ExifRepositoryRef = ProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mobile/lib/providers/infrastructure/store.provider.dart b/mobile/lib/providers/infrastructure/store.provider.dart new file mode 100644 index 0000000000..2712208e76 --- /dev/null +++ b/mobile/lib/providers/infrastructure/store.provider.dart @@ -0,0 +1,11 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'store.provider.g.dart'; + +@riverpod +IStoreRepository storeRepository(Ref ref) => + IsarStoreRepository(ref.watch(isarProvider)); diff --git a/mobile/lib/providers/infrastructure/store.provider.g.dart b/mobile/lib/providers/infrastructure/store.provider.g.dart new file mode 100644 index 0000000000..ebf1804704 --- /dev/null +++ b/mobile/lib/providers/infrastructure/store.provider.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'store.provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$storeRepositoryHash() => r'9f378b96e552151fa14a8c8ce2c30a5f38f436ed'; + +/// See also [storeRepository]. +@ProviderFor(storeRepository) +final storeRepositoryProvider = AutoDisposeProvider.internal( + storeRepository, + name: r'storeRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$storeRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef StoreRepositoryRef = AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mobile/lib/providers/infrastructure/sync_stream.provider.dart b/mobile/lib/providers/infrastructure/sync_stream.provider.dart new file mode 100644 index 0000000000..64f1a6cb05 --- /dev/null +++ b/mobile/lib/providers/infrastructure/sync_stream.provider.dart @@ -0,0 +1,24 @@ +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.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/providers/api.provider.dart'; + +final syncStreamServiceProvider = Provider( + (ref) { + final instance = SyncStreamService( + ref.watch(syncApiRepositoryProvider), + ); + + ref.onDispose(() => unawaited(instance.dispose())); + + return instance; + }, +); + +final syncApiRepositoryProvider = Provider( + (ref) => SyncApiRepository( + ref.watch(apiServiceProvider), + ), +); diff --git a/mobile/lib/providers/map/map_marker.provider.dart b/mobile/lib/providers/map/map_marker.provider.dart index c8e8a77c17..23342b77b3 100644 --- a/mobile/lib/providers/map/map_marker.provider.dart +++ b/mobile/lib/providers/map/map_marker.provider.dart @@ -1,3 +1,4 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/models/map/map_marker.model.dart'; import 'package:immich_mobile/providers/map/map_service.provider.dart'; import 'package:immich_mobile/providers/map/map_state.provider.dart'; @@ -6,7 +7,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'map_marker.provider.g.dart'; @riverpod -Future> mapMarkers(MapMarkersRef ref) async { +Future> mapMarkers(Ref ref) async { final service = ref.read(mapServiceProvider); final mapState = ref.read(mapStateNotifierProvider); DateTime? fileCreatedAfter; diff --git a/mobile/lib/providers/map/map_marker.provider.g.dart b/mobile/lib/providers/map/map_marker.provider.g.dart index ce11b4ebff..76cc44a103 100644 --- a/mobile/lib/providers/map/map_marker.provider.g.dart +++ b/mobile/lib/providers/map/map_marker.provider.g.dart @@ -6,7 +6,7 @@ part of 'map_marker.provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$mapMarkersHash() => r'737d52f3d02e6a458b11d730f2fe522c39ee1ebf'; +String _$mapMarkersHash() => r'f33ac4baa3251b3f06423aece89673315966f885'; /// See also [mapMarkers]. @ProviderFor(mapMarkers) @@ -19,6 +19,8 @@ final mapMarkersProvider = AutoDisposeFutureProvider>.internal( allTransitiveDependencies: null, ); +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element typedef MapMarkersRef = AutoDisposeFutureProviderRef>; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mobile/lib/providers/map/map_service.provider.dart b/mobile/lib/providers/map/map_service.provider.dart index 2773f7dcc9..0d998c5173 100644 --- a/mobile/lib/providers/map/map_service.provider.dart +++ b/mobile/lib/providers/map/map_service.provider.dart @@ -1,3 +1,4 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/services/map.service.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -5,5 +6,4 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'map_service.provider.g.dart'; @riverpod -MapSerivce mapService(MapServiceRef ref) => - MapSerivce(ref.watch(apiServiceProvider)); +MapSerivce mapService(Ref ref) => MapSerivce(ref.watch(apiServiceProvider)); diff --git a/mobile/lib/providers/map/map_service.provider.g.dart b/mobile/lib/providers/map/map_service.provider.g.dart index 7b4e68eaee..70e44da621 100644 --- a/mobile/lib/providers/map/map_service.provider.g.dart +++ b/mobile/lib/providers/map/map_service.provider.g.dart @@ -6,7 +6,7 @@ part of 'map_service.provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$mapServiceHash() => r'2f68c07ac6cd5c74ec8be3bd2df91f4db673b79e'; +String _$mapServiceHash() => r'7b26bcd231ed5728ac51fe015dddbf8f91491abb'; /// See also [mapService]. @ProviderFor(mapService) @@ -19,6 +19,8 @@ final mapServiceProvider = AutoDisposeProvider.internal( allTransitiveDependencies: null, ); +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element typedef MapServiceRef = AutoDisposeProviderRef; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mobile/lib/providers/map/map_state.provider.g.dart b/mobile/lib/providers/map/map_state.provider.g.dart index 23a570d1c8..85a237099c 100644 --- a/mobile/lib/providers/map/map_state.provider.g.dart +++ b/mobile/lib/providers/map/map_state.provider.g.dart @@ -23,4 +23,4 @@ final mapStateNotifierProvider = typedef _$MapStateNotifier = Notifier; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mobile/lib/providers/partner.provider.dart b/mobile/lib/providers/partner.provider.dart index bf638ae355..282e779432 100644 --- a/mobile/lib/providers/partner.provider.dart +++ b/mobile/lib/providers/partner.provider.dart @@ -5,19 +5,19 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/album/suggested_shared_users.provider.dart'; import 'package:immich_mobile/services/partner.service.dart'; import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:isar/isar.dart'; class PartnerSharedWithNotifier extends StateNotifier> { - PartnerSharedWithNotifier(Isar db, this._ps) : super([]) { + final PartnerService _partnerService; + late final StreamSubscription> streamSub; + + PartnerSharedWithNotifier(this._partnerService) : super([]) { Function eq = const ListEquality().equals; - final query = db.users.filter().isPartnerSharedWithEqualTo(true).sortById(); - query.findAll().then((partners) { + _partnerService.getSharedWith().then((partners) { if (!eq(state, partners)) { state = partners; } }).then((_) { - query.watch().listen((partners) { + streamSub = _partnerService.watchSharedWith().listen((partners) { if (!eq(state, partners)) { state = partners; } @@ -26,30 +26,37 @@ class PartnerSharedWithNotifier extends StateNotifier> { } Future updatePartner(User partner, {required bool inTimeline}) { - return _ps.updatePartner(partner, inTimeline: inTimeline); + return _partnerService.updatePartner(partner, inTimeline: inTimeline); } - final PartnerService _ps; + @override + void dispose() { + if (mounted) { + streamSub.cancel(); + } + super.dispose(); + } } final partnerSharedWithProvider = StateNotifierProvider>((ref) { return PartnerSharedWithNotifier( - ref.watch(dbProvider), ref.watch(partnerServiceProvider), ); }); class PartnerSharedByNotifier extends StateNotifier> { - PartnerSharedByNotifier(Isar db) : super([]) { + final PartnerService _partnerService; + late final StreamSubscription> streamSub; + + PartnerSharedByNotifier(this._partnerService) : super([]) { Function eq = const ListEquality().equals; - final query = db.users.filter().isPartnerSharedByEqualTo(true).sortById(); - query.findAll().then((partners) { + _partnerService.getSharedBy().then((partners) { if (!eq(state, partners)) { state = partners; } }).then((_) { - streamSub = query.watch().listen((partners) { + streamSub = _partnerService.watchSharedBy().listen((partners) { if (!eq(state, partners)) { state = partners; } @@ -57,18 +64,18 @@ class PartnerSharedByNotifier extends StateNotifier> { }); } - late final StreamSubscription> streamSub; - @override void dispose() { - streamSub.cancel(); + if (mounted) { + streamSub.cancel(); + } super.dispose(); } } final partnerSharedByProvider = StateNotifierProvider>((ref) { - return PartnerSharedByNotifier(ref.watch(dbProvider)); + return PartnerSharedByNotifier(ref.watch(partnerServiceProvider)); }); final partnerAvailableProvider = diff --git a/mobile/lib/providers/search/all_motion_photos.provider.dart b/mobile/lib/providers/search/all_motion_photos.provider.dart index 1740613e58..48bc1bb80c 100644 --- a/mobile/lib/providers/search/all_motion_photos.provider.dart +++ b/mobile/lib/providers/search/all_motion_photos.provider.dart @@ -1,13 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:isar/isar.dart'; +import 'package:immich_mobile/services/asset.service.dart'; final allMotionPhotosProvider = FutureProvider>((ref) async { - return ref - .watch(dbProvider) - .assets - .filter() - .livePhotoVideoIdIsNotNull() - .findAll(); + return ref.watch(assetServiceProvider).getMotionAssets(); }); diff --git a/mobile/lib/providers/search/all_video_assets.provider.dart b/mobile/lib/providers/search/all_video_assets.provider.dart deleted file mode 100644 index b0daf6b984..0000000000 --- a/mobile/lib/providers/search/all_video_assets.provider.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/utils/renderlist_generator.dart'; - -final allVideoAssetsProvider = StreamProvider((ref) { - final query = ref - .watch(dbProvider) - .assets - .filter() - .isArchivedEqualTo(false) - .isTrashedEqualTo(false) - .typeEqualTo(AssetType.video) - .sortByFileCreatedAtDesc(); - return renderListGenerator(query, ref); -}); diff --git a/mobile/lib/providers/search/paginated_search.provider.dart b/mobile/lib/providers/search/paginated_search.provider.dart index 270f1148e8..bac5c5e77e 100644 --- a/mobile/lib/providers/search/paginated_search.provider.dart +++ b/mobile/lib/providers/search/paginated_search.provider.dart @@ -1,6 +1,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/models/search/search_result.model.dart'; -import 'package:immich_mobile/providers/asset_viewer/render_list.provider.dart'; +import 'package:immich_mobile/services/timeline.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/services/search.service.dart'; @@ -19,17 +19,23 @@ class PaginatedSearchNotifier extends StateNotifier { PaginatedSearchNotifier(this._searchService) : super(SearchResult(assets: [], nextPage: 1)); - search(SearchFilter filter) async { - if (state.nextPage == null) return; + Future search(SearchFilter filter) async { + if (state.nextPage == null) { + return false; + } final result = await _searchService.search(filter, state.nextPage!); - if (result == null) return; + if (result == null) { + return false; + } state = SearchResult( assets: [...state.assets, ...result.assets], nextPage: result.nextPage, ); + + return true; } clear() { @@ -38,14 +44,13 @@ class PaginatedSearchNotifier extends StateNotifier { } @riverpod -AsyncValue paginatedSearchRenderList( - PaginatedSearchRenderListRef ref, +Future paginatedSearchRenderList( + Ref ref, ) { final result = ref.watch(paginatedSearchProvider); - - return ref.watch( - renderListProviderWithGrouping( - (result.assets, GroupAssetsBy.none), - ), + final timelineService = ref.watch(timelineServiceProvider); + return timelineService.getTimelineFromAssets( + result.assets, + GroupAssetsBy.none, ); } diff --git a/mobile/lib/providers/search/paginated_search.provider.g.dart b/mobile/lib/providers/search/paginated_search.provider.g.dart index cdf8cdd741..650cf130fc 100644 --- a/mobile/lib/providers/search/paginated_search.provider.g.dart +++ b/mobile/lib/providers/search/paginated_search.provider.g.dart @@ -7,12 +7,12 @@ part of 'paginated_search.provider.dart'; // ************************************************************************** String _$paginatedSearchRenderListHash() => - r'4585c832106b16b6d294055f47bbbe83e0802846'; + r'22d715ff7864e5a946be38322ce7813616f899c2'; /// See also [paginatedSearchRenderList]. @ProviderFor(paginatedSearchRenderList) final paginatedSearchRenderListProvider = - AutoDisposeProvider>.internal( + AutoDisposeFutureProvider.internal( paginatedSearchRenderList, name: r'paginatedSearchRenderListProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') @@ -22,7 +22,8 @@ final paginatedSearchRenderListProvider = allTransitiveDependencies: null, ); -typedef PaginatedSearchRenderListRef - = AutoDisposeProviderRef>; +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef PaginatedSearchRenderListRef = AutoDisposeFutureProviderRef; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mobile/lib/providers/search/people.provider.dart b/mobile/lib/providers/search/people.provider.dart index 7c956f0a37..d1370f498f 100644 --- a/mobile/lib/providers/search/people.provider.dart +++ b/mobile/lib/providers/search/people.provider.dart @@ -1,3 +1,4 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/interfaces/person_api.interface.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/services/person.service.dart'; @@ -9,7 +10,7 @@ part 'people.provider.g.dart'; @riverpod Future> getAllPeople( - GetAllPeopleRef ref, + Ref ref, ) async { final PersonService personService = ref.read(personServiceProvider); @@ -19,7 +20,7 @@ Future> getAllPeople( } @riverpod -Future personAssets(PersonAssetsRef ref, String personId) async { +Future personAssets(Ref ref, String personId) async { final PersonService personService = ref.read(personServiceProvider); final assets = await personService.getPersonAssets(personId); @@ -31,7 +32,7 @@ Future personAssets(PersonAssetsRef ref, String personId) async { @riverpod Future updatePersonName( - UpdatePersonNameRef ref, + Ref ref, String personId, String updatedName, ) async { diff --git a/mobile/lib/providers/search/people.provider.g.dart b/mobile/lib/providers/search/people.provider.g.dart index c5ff6287cd..391edd362c 100644 --- a/mobile/lib/providers/search/people.provider.g.dart +++ b/mobile/lib/providers/search/people.provider.g.dart @@ -6,7 +6,7 @@ part of 'people.provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$getAllPeopleHash() => r'3417b7e0c211382d4480a415e352139995d57b6d'; +String _$getAllPeopleHash() => r'226947af3b09ce62224916543958dd1d5e2ba651'; /// See also [getAllPeople]. @ProviderFor(getAllPeople) @@ -19,8 +19,10 @@ final getAllPeopleProvider = AutoDisposeFutureProvider>.internal( allTransitiveDependencies: null, ); +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element typedef GetAllPeopleRef = AutoDisposeFutureProviderRef>; -String _$personAssetsHash() => r'3dfecb67a54d07e4208bcb9581b2625acd2e1832'; +String _$personAssetsHash() => r'c1d35ee0e024bd6915e21bc724be4b458a14bc24'; /// Copied from Dart SDK class _SystemHash { @@ -156,6 +158,8 @@ class PersonAssetsProvider extends AutoDisposeFutureProvider { } } +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element mixin PersonAssetsRef on AutoDisposeFutureProviderRef { /// The parameter `personId` of this provider. String get personId; @@ -169,7 +173,7 @@ class _PersonAssetsProviderElement String get personId => (origin as PersonAssetsProvider).personId; } -String _$updatePersonNameHash() => r'7145aaaf6fc38fdafe3a283ebf3d3f4fd0774cd2'; +String _$updatePersonNameHash() => r'45f7693172de522a227406d8198811434cf2bbbc'; /// See also [updatePersonName]. @ProviderFor(updatePersonName) @@ -296,6 +300,8 @@ class UpdatePersonNameProvider extends AutoDisposeFutureProvider { } } +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element mixin UpdatePersonNameRef on AutoDisposeFutureProviderRef { /// The parameter `personId` of this provider. String get personId; @@ -314,4 +320,4 @@ class _UpdatePersonNameProviderElement String get updatedName => (origin as UpdatePersonNameProvider).updatedName; } // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mobile/lib/providers/search/recently_added_asset.provider.dart b/mobile/lib/providers/search/recently_added_asset.provider.dart index bf728ba095..c4819d9d44 100644 --- a/mobile/lib/providers/search/recently_added_asset.provider.dart +++ b/mobile/lib/providers/search/recently_added_asset.provider.dart @@ -1,18 +1,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:isar/isar.dart'; +import 'package:immich_mobile/services/asset.service.dart'; final recentlyAddedAssetProvider = FutureProvider>((ref) async { - final user = ref.read(currentUserProvider); - if (user == null) return []; + final assetService = ref.read(assetServiceProvider); - return ref - .watch(dbProvider) - .assets - .where() - .ownerIdEqualToAnyChecksum(user.isarId) - .sortByFileCreatedAtDesc() - .findAll(); + return assetService.getRecentlyAddedAssets(); }); diff --git a/mobile/lib/providers/search/search_filter.provider.dart b/mobile/lib/providers/search/search_filter.provider.dart index 9086fc861f..2a81060522 100644 --- a/mobile/lib/providers/search/search_filter.provider.dart +++ b/mobile/lib/providers/search/search_filter.provider.dart @@ -1,3 +1,4 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/services/search.service.dart'; import 'package:openapi/api.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -6,7 +7,7 @@ part 'search_filter.provider.g.dart'; @riverpod Future> getSearchSuggestions( - GetSearchSuggestionsRef ref, + Ref ref, SearchSuggestionType type, { String? locationCountry, String? locationState, diff --git a/mobile/lib/providers/search/search_filter.provider.g.dart b/mobile/lib/providers/search/search_filter.provider.g.dart index d5cdaa0312..03f88b0332 100644 --- a/mobile/lib/providers/search/search_filter.provider.g.dart +++ b/mobile/lib/providers/search/search_filter.provider.g.dart @@ -7,7 +7,7 @@ part of 'search_filter.provider.dart'; // ************************************************************************** String _$getSearchSuggestionsHash() => - r'bc1e9a1a060868f14e6eb970d2251dbfe39c6866'; + r'bc30a65e8fcb273cbd07bab876baf67bcc794737'; /// Copied from Dart SDK class _SystemHash { @@ -189,6 +189,8 @@ class GetSearchSuggestionsProvider } } +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element mixin GetSearchSuggestionsRef on AutoDisposeFutureProviderRef> { /// The parameter `type` of this provider. SearchSuggestionType get type; @@ -226,4 +228,4 @@ class _GetSearchSuggestionsProviderElement String? get model => (origin as GetSearchSuggestionsProvider).model; } // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mobile/lib/providers/timeline.provider.dart b/mobile/lib/providers/timeline.provider.dart new file mode 100644 index 0000000000..97d5698c4c --- /dev/null +++ b/mobile/lib/providers/timeline.provider.dart @@ -0,0 +1,74 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/providers/locale_provider.dart'; +import 'package:immich_mobile/services/timeline.service.dart'; +import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; + +final singleUserTimelineProvider = StreamProvider.family( + (ref, userId) { + if (userId == null) { + return const Stream.empty(); + } + + ref.watch(localeProvider); + final timelineService = ref.watch(timelineServiceProvider); + return timelineService.watchHomeTimeline(userId); + }, + dependencies: [localeProvider], +); + +final multiUsersTimelineProvider = StreamProvider.family>( + (ref, userIds) { + ref.watch(localeProvider); + final timelineService = ref.watch(timelineServiceProvider); + return timelineService.watchMultiUsersTimeline(userIds); + }, + dependencies: [localeProvider], +); + +final albumTimelineProvider = + StreamProvider.autoDispose.family((ref, id) { + final album = ref.watch(albumWatcher(id)).value; + final timelineService = ref.watch(timelineServiceProvider); + + if (album != null) { + return timelineService.watchAlbumTimeline(album); + } + + return const Stream.empty(); +}); + +final archiveTimelineProvider = StreamProvider((ref) { + final timelineService = ref.watch(timelineServiceProvider); + return timelineService.watchArchiveTimeline(); +}); + +final favoriteTimelineProvider = StreamProvider((ref) { + final timelineService = ref.watch(timelineServiceProvider); + return timelineService.watchFavoriteTimeline(); +}); + +final trashTimelineProvider = StreamProvider((ref) { + final timelineService = ref.watch(timelineServiceProvider); + return timelineService.watchTrashTimeline(); +}); + +final allVideosTimelineProvider = StreamProvider((ref) { + final timelineService = ref.watch(timelineServiceProvider); + return timelineService.watchAllVideosTimeline(); +}); + +final assetSelectionTimelineProvider = StreamProvider((ref) { + final timelineService = ref.watch(timelineServiceProvider); + return timelineService.watchAssetSelectionTimeline(); +}); + +final assetsTimelineProvider = + FutureProvider.family>((ref, assets) { + final timelineService = ref.watch(timelineServiceProvider); + return timelineService.getTimelineFromAssets( + assets, + null, + ); +}); diff --git a/mobile/lib/providers/trash.provider.dart b/mobile/lib/providers/trash.provider.dart index 8bbac853c7..c78cccff8a 100644 --- a/mobile/lib/providers/trash.provider.dart +++ b/mobile/lib/providers/trash.provider.dart @@ -1,151 +1,43 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/services/trash.service.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/asset.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/services/sync.service.dart'; -import 'package:immich_mobile/utils/renderlist_generator.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; class TrashNotifier extends StateNotifier { - final Isar _db; - final Ref _ref; final TrashService _trashService; final _log = Logger('TrashNotifier'); TrashNotifier( this._trashService, - this._db, - this._ref, ) : super(false); Future emptyTrash() async { try { - final user = _ref.read(currentUserProvider); - if (user == null) { - return; - } await _trashService.emptyTrash(); - - final idsToRemove = await _db.assets - .where() - .remoteIdIsNotNull() - .filter() - .ownerIdEqualTo(user.isarId) - .isTrashedEqualTo(true) - .remoteIdProperty() - .findAll(); - - // TODO: handle local asset removal on emptyTrash - _ref - .read(syncServiceProvider) - .handleRemoteAssetRemoval(idsToRemove.cast().toList()); + state = true; } catch (error, stack) { _log.severe("Cannot empty trash", error, stack); + state = false; } } - Future removeAssets(Iterable assetList) async { - try { - final user = _ref.read(currentUserProvider); - if (user == null) { - return false; - } - - final isRemoved = await _ref - .read(assetProvider.notifier) - .deleteRemoteOnlyAssets(assetList, force: true); - - if (isRemoved) { - final idsToRemove = - assetList.where((a) => a.isRemote).map((a) => a.remoteId!).toList(); - - _ref - .read(syncServiceProvider) - .handleRemoteAssetRemoval(idsToRemove.cast().toList()); - } - - return isRemoved; - } catch (error, stack) { - _log.severe("Cannot remove assets", error, stack); - } - return false; - } - - Future restoreAsset(Asset asset) async { - try { - final result = await _trashService.restoreAsset(asset); - - if (result) { - final remoteAsset = asset.isRemote; - - asset.isTrashed = false; - - if (remoteAsset) { - await _db.writeTxn(() async { - await _db.assets.put(asset); - }); - } - return true; - } - } catch (error, stack) { - _log.severe("Cannot restore asset", error, stack); - } - return false; - } - Future restoreAssets(Iterable assetList) async { try { - final result = await _trashService.restoreAssets(assetList); - - if (result) { - final remoteAssets = assetList.where((a) => a.isRemote).toList(); - - final updatedAssets = remoteAssets.map((e) { - e.isTrashed = false; - return e; - }).toList(); - - await _db.writeTxn(() async { - await _db.assets.putAll(updatedAssets); - }); - return true; - } + await _trashService.restoreAssets(assetList); + return true; } catch (error, stack) { _log.severe("Cannot restore assets", error, stack); + return false; } - return false; } Future restoreTrash() async { try { - final user = _ref.read(currentUserProvider); - if (user == null) { - return; - } await _trashService.restoreTrash(); - - final assets = await _db.assets - .where() - .remoteIdIsNotNull() - .filter() - .ownerIdEqualTo(user.isarId) - .isTrashedEqualTo(true) - .findAll(); - - final updatedAssets = assets.map((e) { - e.isTrashed = false; - return e; - }).toList(); - - await _db.writeTxn(() async { - await _db.assets.putAll(updatedAssets); - }); + state = true; } catch (error, stack) { _log.severe("Cannot restore trash", error, stack); + state = false; } } } @@ -153,20 +45,5 @@ class TrashNotifier extends StateNotifier { final trashProvider = StateNotifierProvider((ref) { return TrashNotifier( ref.watch(trashServiceProvider), - ref.watch(dbProvider), - ref, ); }); - -final trashedAssetsProvider = StreamProvider((ref) { - final user = ref.read(currentUserProvider); - if (user == null) return const Stream.empty(); - final query = ref - .watch(dbProvider) - .assets - .filter() - .ownerIdEqualTo(user.isarId) - .isTrashedEqualTo(true) - .sortByFileCreatedAtDesc(); - return renderListGeneratorWithGroupBy(query, GroupAssetsBy.none); -}); diff --git a/mobile/lib/providers/user.provider.dart b/mobile/lib/providers/user.provider.dart index 971cfd5103..0a1bc0275a 100644 --- a/mobile/lib/providers/user.provider.dart +++ b/mobile/lib/providers/user.provider.dart @@ -1,12 +1,12 @@ import 'dart:async'; 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/entities/user.entity.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; +import 'package:immich_mobile/services/timeline.service.dart'; class CurrentUserProvider extends StateNotifier { CurrentUserProvider(this._apiService) : super(null) { @@ -23,7 +23,7 @@ class CurrentUserProvider extends StateNotifier { final user = await _apiService.usersApi.getMyUser(); final userPreferences = await _apiService.usersApi.getMyPreferences(); if (user != null) { - Store.put( + await Store.put( StoreKey.currentUser, User.fromUserDto(user, userPreferences), ); @@ -46,18 +46,15 @@ final currentUserProvider = }); class TimelineUserIdsProvider extends StateNotifier> { - TimelineUserIdsProvider(Isar db, User? currentUser) : super([]) { - final query = db.users - .filter() - .inTimelineEqualTo(true) - .or() - .isarIdEqualTo(currentUser?.isarId ?? Isar.autoIncrement) - .isarIdProperty(); - query.findAll().then((users) => state = users); - streamSub = query.watch().listen((users) => state = users); + TimelineUserIdsProvider(this._timelineService) : super([]) { + _timelineService.getTimelineUserIds().then((users) => state = users); + streamSub = _timelineService + .watchTimelineUserIds() + .listen((users) => state = users); } late final StreamSubscription> streamSub; + final TimelineService _timelineService; @override void dispose() { @@ -68,8 +65,5 @@ class TimelineUserIdsProvider extends StateNotifier> { final timelineUsersIdsProvider = StateNotifierProvider>((ref) { - return TimelineUserIdsProvider( - ref.watch(dbProvider), - ref.watch(currentUserProvider), - ); + return TimelineUserIdsProvider(ref.watch(timelineServiceProvider)); }); diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index 6889db7b7f..f92d2c8421 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -4,11 +4,12 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/models/server_info/server_version.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/models/server_info/server_version.model.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; diff --git a/mobile/lib/repositories/album.repository.dart b/mobile/lib/repositories/album.repository.dart index adf83b33d4..1d2df89579 100644 --- a/mobile/lib/repositories/album.repository.dart +++ b/mobile/lib/repositories/album.repository.dart @@ -1,4 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; @@ -18,15 +19,11 @@ class AlbumRepository extends DatabaseRepository implements IAlbumRepository { @override Future count({bool? local}) { final baseQuery = db.albums.where(); - final QueryBuilder query; - switch (local) { - case null: - query = baseQuery.noOp(); - case true: - query = baseQuery.localIdIsNotNull(); - case false: - query = baseQuery.remoteIdIsNotNull(); - } + final QueryBuilder query = switch (local) { + null => baseQuery.noOp(), + true => baseQuery.localIdIsNotNull(), + false => baseQuery.remoteIdIsNotNull(), + }; return query.count(); } @@ -91,15 +88,11 @@ class AlbumRepository extends DatabaseRepository implements IAlbumRepository { if (ownerId != null) { filterQuery = filterQuery.owner((q) => q.isarIdEqualTo(ownerId)); } - final QueryBuilder query; - switch (sortBy) { - case null: - query = filterQuery.noOp(); - case AlbumSort.remoteId: - query = filterQuery.sortByRemoteId(); - case AlbumSort.localId: - query = filterQuery.sortByLocalId(); - } + final QueryBuilder query = switch (sortBy) { + null => filterQuery.noOp(), + AlbumSort.remoteId => filterQuery.sortByRemoteId(), + AlbumSort.localId => filterQuery.sortByLocalId(), + }; return query.findAll(); } @@ -150,17 +143,36 @@ class AlbumRepository extends DatabaseRepository implements IAlbumRepository { query = query.owner( (q) => q.not().isarIdEqualTo(Store.get(StoreKey.currentUser).isarId), ); - break; case QuickFilterMode.myAlbums: query = query.owner( (q) => q.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId), ); - break; case QuickFilterMode.all: - default: break; } return await query.findAll(); } + + @override + Future clearTable() async { + await txn(() async { + await db.albums.clear(); + }); + } + + @override + Stream> watchRemoteAlbums() { + return db.albums.where().remoteIdIsNotNull().watch(); + } + + @override + Stream> watchLocalAlbums() { + return db.albums.where().localIdIsNotNull().watch(); + } + + @override + Stream watchAlbum(int id) { + return db.albums.watchObject(id, fireImmediately: true); + } } diff --git a/mobile/lib/repositories/album_media.repository.dart b/mobile/lib/repositories/album_media.repository.dart index c3795f75df..f4f31cf14e 100644 --- a/mobile/lib/repositories/album_media.repository.dart +++ b/mobile/lib/repositories/album_media.repository.dart @@ -1,4 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; diff --git a/mobile/lib/repositories/asset.repository.dart b/mobile/lib/repositories/asset.repository.dart index eaaafd3045..c27660c352 100644 --- a/mobile/lib/repositories/asset.repository.dart +++ b/mobile/lib/repositories/asset.repository.dart @@ -6,8 +6,8 @@ import 'package:immich_mobile/entities/android_device_asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/device_asset.entity.dart'; import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/repositories/database.repository.dart'; @@ -38,33 +38,26 @@ class AssetRepository extends DatabaseRepository implements IAssetRepository { query = query.ownerIdEqualTo(ownerId); } - switch (state) { - case null: - break; - case AssetState.local: - query = query.remoteIdIsNull(); - case AssetState.remote: - query = query.localIdIsNull(); - case AssetState.merged: - query = query.localIdIsNotNull().remoteIdIsNotNull(); + if (state != null) { + query = switch (state) { + AssetState.local => query.remoteIdIsNull(), + AssetState.remote => query.localIdIsNull(), + AssetState.merged => query.localIdIsNotNull().remoteIdIsNotNull(), + }; } - final QueryBuilder sortedQuery; - - switch (sortBy) { - case null: - sortedQuery = query.noOp(); - case AssetSort.checksum: - sortedQuery = query.sortByChecksum(); - case AssetSort.ownerIdChecksum: - sortedQuery = query.sortByOwnerId().thenByChecksum(); - } + final QueryBuilder sortedQuery = + switch (sortBy) { + null => query.noOp(), + AssetSort.checksum => query.sortByChecksum(), + AssetSort.ownerIdChecksum => query.sortByOwnerId().thenByChecksum(), + }; return sortedQuery.findAll(); } @override - Future deleteById(List ids) => txn(() async { + Future deleteByIds(List ids) => txn(() async { await db.assets.deleteAll(ids); await db.exifInfos.deleteAll(ids); }); @@ -84,16 +77,12 @@ class AssetRepository extends DatabaseRepository implements IAssetRepository { AssetState? state, ) { final query = db.assets.remote(ids).filter(); - switch (state) { - case null: - return query.noOp(); - case AssetState.local: - return query.remoteIdIsNull(); - case AssetState.remote: - return query.localIdIsNull(); - case AssetState.merged: - return query.localIdIsNotEmpty().remoteIdIsNotNull(); - } + return switch (state) { + null => query.noOp(), + AssetState.local => query.remoteIdIsNull(), + AssetState.remote => query.localIdIsNull(), + AssetState.merged => query.localIdIsNotEmpty().remoteIdIsNotNull(), + }; } @override @@ -104,39 +93,32 @@ class AssetRepository extends DatabaseRepository implements IAssetRepository { int? limit, }) { final baseQuery = db.assets.where(); - final QueryBuilder filteredQuery; - switch (state) { - case null: - filteredQuery = baseQuery.ownerIdEqualToAnyChecksum(ownerId).noOp(); - case AssetState.local: - filteredQuery = baseQuery - .remoteIdIsNull() - .filter() - .localIdIsNotNull() - .ownerIdEqualTo(ownerId); - case AssetState.remote: - filteredQuery = baseQuery - .localIdIsNull() - .filter() - .remoteIdIsNotNull() - .ownerIdEqualTo(ownerId); - case AssetState.merged: - filteredQuery = baseQuery - .ownerIdEqualToAnyChecksum(ownerId) - .filter() - .remoteIdIsNotNull() - .localIdIsNotNull(); - } + final QueryBuilder filteredQuery = + switch (state) { + null => baseQuery.ownerIdEqualToAnyChecksum(ownerId).noOp(), + AssetState.local => baseQuery + .remoteIdIsNull() + .filter() + .localIdIsNotNull() + .ownerIdEqualTo(ownerId), + AssetState.remote => baseQuery + .localIdIsNull() + .filter() + .remoteIdIsNotNull() + .ownerIdEqualTo(ownerId), + AssetState.merged => baseQuery + .ownerIdEqualToAnyChecksum(ownerId) + .filter() + .remoteIdIsNotNull() + .localIdIsNotNull(), + }; - final QueryBuilder query; - switch (sortBy) { - case null: - query = filteredQuery.noOp(); - case AssetSort.checksum: - query = filteredQuery.sortByChecksum(); - case AssetSort.ownerIdChecksum: - query = filteredQuery.sortByOwnerId().thenByChecksum(); - } + final QueryBuilder query = switch (sortBy) { + null => filteredQuery.noOp(), + AssetSort.checksum => filteredQuery.sortByChecksum(), + AssetSort.ownerIdChecksum => + filteredQuery.sortByOwnerId().thenByChecksum(), + }; return limit == null ? query.findAll() : query.limit(limit).findAll(); } @@ -155,17 +137,16 @@ class AssetRepository extends DatabaseRepository implements IAssetRepository { int limit = 100, }) { final baseQuery = db.assets.where(); - final QueryBuilder query; - switch (state) { - case null: - query = baseQuery.noOp(); - case AssetState.local: - query = baseQuery.remoteIdIsNull().filter().localIdIsNotNull(); - case AssetState.remote: - query = baseQuery.localIdIsNull().filter().remoteIdIsNotNull(); - case AssetState.merged: - query = baseQuery.localIdIsNotNull().filter().remoteIdIsNotNull(); - } + final QueryBuilder query = + switch (state) { + null => baseQuery.noOp(), + AssetState.local => + baseQuery.remoteIdIsNull().filter().localIdIsNotNull(), + AssetState.remote => + baseQuery.localIdIsNull().filter().remoteIdIsNotNull(), + AssetState.merged => + baseQuery.localIdIsNotNull().filter().remoteIdIsNotNull(), + }; return _getMatchesImpl(query, ownerId, assets, limit); } @@ -216,6 +197,61 @@ class AssetRepository extends DatabaseRepository implements IAssetRepository { @override Future deleteAllByRemoteId(List ids, {AssetState? state}) => txn(() => _getAllByRemoteIdImpl(ids, state).deleteAll()); + + @override + Future> getStackAssets(String stackId) { + return db.assets + .filter() + .isArchivedEqualTo(false) + .isTrashedEqualTo(false) + .stackIdEqualTo(stackId) + // orders primary asset first as its ID is null + .sortByStackPrimaryAssetId() + .thenByFileCreatedAtDesc() + .findAll(); + } + + @override + Future clearTable() async { + await txn(() async { + await db.assets.clear(); + }); + } + + @override + Stream watchAsset(int id, {bool fireImmediately = false}) { + return db.assets.watchObject(id, fireImmediately: fireImmediately); + } + + @override + Future> getTrashAssets(int userId) { + return db.assets + .where() + .remoteIdIsNotNull() + .filter() + .ownerIdEqualTo(userId) + .isTrashedEqualTo(true) + .findAll(); + } + + @override + Future> getRecentlyAddedAssets(int userId) { + return db.assets + .where() + .ownerIdEqualToAnyChecksum(userId) + .sortByFileCreatedAtDesc() + .findAll(); + } + + @override + Future> getMotionAssets(int userId) { + return db.assets + .where() + .ownerIdEqualToAnyChecksum(userId) + .filter() + .livePhotoVideoIdIsNotNull() + .findAll(); + } } Future> _getMatchesImpl( diff --git a/mobile/lib/repositories/asset_media.repository.dart b/mobile/lib/repositories/asset_media.repository.dart index 68fffa08a6..97d22f3600 100644 --- a/mobile/lib/repositories/asset_media.repository.dart +++ b/mobile/lib/repositories/asset_media.repository.dart @@ -1,6 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/interfaces/asset_media.interface.dart'; import 'package:photo_manager/photo_manager.dart' hide AssetType; @@ -38,7 +39,8 @@ class AssetMediaRepository implements IAssetMediaRepository { asset.fileCreatedAt = asset.fileModifiedAt; } if (local.latitude != null) { - asset.exifInfo = ExifInfo(lat: local.latitude, long: local.longitude); + asset.exifInfo = + ExifInfo(latitude: local.latitude, longitude: local.longitude); } asset.local = local; return asset; diff --git a/mobile/lib/repositories/auth.repository.dart b/mobile/lib/repositories/auth.repository.dart index fa504e6ac3..18e14865aa 100644 --- a/mobile/lib/repositories/auth.repository.dart +++ b/mobile/lib/repositories/auth.repository.dart @@ -1,12 +1,13 @@ import 'dart:convert'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'; import 'package:immich_mobile/interfaces/auth.interface.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/providers/db.provider.dart'; diff --git a/mobile/lib/repositories/backup.repository.dart b/mobile/lib/repositories/backup.repository.dart index 61997ff23a..f7f3051f46 100644 --- a/mobile/lib/repositories/backup.repository.dart +++ b/mobile/lib/repositories/backup.repository.dart @@ -1,26 +1,25 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; -import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/backup_album.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/repositories/database.repository.dart'; import 'package:isar/isar.dart'; -final backupRepositoryProvider = - Provider((ref) => BackupRepository(ref.watch(dbProvider))); +final backupAlbumRepositoryProvider = + Provider((ref) => BackupAlbumRepository(ref.watch(dbProvider))); -class BackupRepository extends DatabaseRepository implements IBackupRepository { - BackupRepository(super.db); +class BackupAlbumRepository extends DatabaseRepository + implements IBackupAlbumRepository { + BackupAlbumRepository(super.db); @override Future> getAll({BackupAlbumSort? sort}) { final baseQuery = db.backupAlbums.where(); - final QueryBuilder query; - switch (sort) { - case null: - query = baseQuery.noOp(); - case BackupAlbumSort.id: - query = baseQuery.sortById(); - } + final QueryBuilder query = + switch (sort) { + null => baseQuery.noOp(), + BackupAlbumSort.id => baseQuery.sortById(), + }; return query.findAll(); } diff --git a/mobile/lib/repositories/etag.repository.dart b/mobile/lib/repositories/etag.repository.dart index 9921b69f5e..93d98de28c 100644 --- a/mobile/lib/repositories/etag.repository.dart +++ b/mobile/lib/repositories/etag.repository.dart @@ -26,4 +26,11 @@ class ETagRepository extends DatabaseRepository implements IETagRepository { @override Future getById(String id) => db.eTags.getById(id); + + @override + Future clearTable() async { + await txn(() async { + await db.eTags.clear(); + }); + } } diff --git a/mobile/lib/repositories/exif_info.repository.dart b/mobile/lib/repositories/exif_info.repository.dart deleted file mode 100644 index 3ddb50104b..0000000000 --- a/mobile/lib/repositories/exif_info.repository.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; -import 'package:immich_mobile/interfaces/exif_info.interface.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/repositories/database.repository.dart'; - -final exifInfoRepositoryProvider = - Provider((ref) => ExifInfoRepository(ref.watch(dbProvider))); - -class ExifInfoRepository extends DatabaseRepository - implements IExifInfoRepository { - ExifInfoRepository(super.db); - - @override - Future delete(int id) => txn(() => db.exifInfos.delete(id)); - - @override - Future get(int id) => db.exifInfos.get(id); - - @override - Future update(ExifInfo exifInfo) async { - await txn(() => db.exifInfos.put(exifInfo)); - return exifInfo; - } - - @override - Future> updateAll(List exifInfos) async { - await txn(() => db.exifInfos.putAll(exifInfos)); - return exifInfos; - } -} diff --git a/mobile/lib/repositories/folder_api.repository.dart b/mobile/lib/repositories/folder_api.repository.dart new file mode 100644 index 0000000000..bd7b035157 --- /dev/null +++ b/mobile/lib/repositories/folder_api.repository.dart @@ -0,0 +1,43 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/folder_api.interface.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; +import 'package:logging/logging.dart'; +import 'package:openapi/api.dart'; + +final folderApiRepositoryProvider = Provider( + (ref) => FolderApiRepository( + ref.watch(apiServiceProvider).viewApi, + ), +); + +class FolderApiRepository extends ApiRepository + implements IFolderApiRepository { + final ViewApi _api; + final Logger _log = Logger("FolderApiRepository"); + + FolderApiRepository(this._api); + + @override + Future> getAllUniquePaths() async { + try { + final list = await _api.getUniqueOriginalPaths(); + return list ?? []; + } catch (e, stack) { + _log.severe("Failed to fetch unique original links", e, stack); + return []; + } + } + + @override + Future> getAssetsForPath(String? path) async { + try { + final list = await _api.getAssetsByOriginalPath(path ?? '/'); + return list != null ? list.map(Asset.remote).toList() : []; + } catch (e, stack) { + _log.severe("Failed to fetch Assets by original path", e, stack); + return []; + } + } +} diff --git a/mobile/lib/repositories/partner.repository.dart b/mobile/lib/repositories/partner.repository.dart new file mode 100644 index 0000000000..cae49fee39 --- /dev/null +++ b/mobile/lib/repositories/partner.repository.dart @@ -0,0 +1,47 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/partner.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; +import 'package:isar/isar.dart'; + +final partnerRepositoryProvider = Provider( + (ref) => PartnerRepository(ref.watch(dbProvider)), +); + +class PartnerRepository extends DatabaseRepository + implements IPartnerRepository { + PartnerRepository(super.db); + + @override + Future> getSharedBy() { + return db.users + .filter() + .isPartnerSharedByEqualTo(true) + .sortById() + .findAll(); + } + + @override + Future> getSharedWith() { + return db.users + .filter() + .isPartnerSharedWithEqualTo(true) + .sortById() + .findAll(); + } + + @override + Stream> watchSharedBy() { + return db.users.filter().isPartnerSharedByEqualTo(true).sortById().watch(); + } + + @override + Stream> watchSharedWith() { + return db.users + .filter() + .isPartnerSharedWithEqualTo(true) + .sortById() + .watch(); + } +} diff --git a/mobile/lib/repositories/timeline.repository.dart b/mobile/lib/repositories/timeline.repository.dart new file mode 100644 index 0000000000..1b9ee8ad37 --- /dev/null +++ b/mobile/lib/repositories/timeline.repository.dart @@ -0,0 +1,168 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/timeline.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; +import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; +import 'package:isar/isar.dart'; + +final timelineRepositoryProvider = + Provider((ref) => TimelineRepository(ref.watch(dbProvider))); + +class TimelineRepository extends DatabaseRepository + implements ITimelineRepository { + TimelineRepository(super.db); + + @override + Future> getTimelineUserIds(int id) { + return db.users + .filter() + .inTimelineEqualTo(true) + .or() + .isarIdEqualTo(id) + .isarIdProperty() + .findAll(); + } + + @override + Stream> watchTimelineUsers(int id) { + return db.users + .filter() + .inTimelineEqualTo(true) + .or() + .isarIdEqualTo(id) + .isarIdProperty() + .watch(); + } + + @override + Stream watchArchiveTimeline(int userId) { + final query = db.assets + .where() + .ownerIdEqualToAnyChecksum(userId) + .filter() + .isArchivedEqualTo(true) + .isTrashedEqualTo(false) + .sortByFileCreatedAtDesc(); + + return _watchRenderList(query, GroupAssetsBy.none); + } + + @override + Stream watchFavoriteTimeline(int userId) { + final query = db.assets + .where() + .ownerIdEqualToAnyChecksum(userId) + .filter() + .isFavoriteEqualTo(true) + .isTrashedEqualTo(false) + .sortByFileCreatedAtDesc(); + + return _watchRenderList(query, GroupAssetsBy.none); + } + + @override + Stream watchAlbumTimeline( + Album album, + GroupAssetsBy groupAssetByOption, + ) { + final query = album.assets.filter().isTrashedEqualTo(false); + final withSortedOption = switch (album.sortOrder) { + SortOrder.asc => query.sortByFileCreatedAt(), + SortOrder.desc => query.sortByFileCreatedAtDesc(), + }; + + return _watchRenderList(withSortedOption, groupAssetByOption); + } + + @override + Stream watchTrashTimeline(int userId) { + final query = db.assets + .filter() + .ownerIdEqualTo(userId) + .isTrashedEqualTo(true) + .sortByFileCreatedAtDesc(); + + return _watchRenderList(query, GroupAssetsBy.none); + } + + @override + Stream watchAllVideosTimeline() { + final query = db.assets + .filter() + .isArchivedEqualTo(false) + .isTrashedEqualTo(false) + .typeEqualTo(AssetType.video) + .sortByFileCreatedAtDesc(); + + return _watchRenderList(query, GroupAssetsBy.none); + } + + @override + Stream watchHomeTimeline( + int userId, + GroupAssetsBy groupAssetByOption, + ) { + final query = db.assets + .where() + .ownerIdEqualToAnyChecksum(userId) + .filter() + .isArchivedEqualTo(false) + .isTrashedEqualTo(false) + .stackPrimaryAssetIdIsNull() + .sortByFileCreatedAtDesc(); + + return _watchRenderList(query, groupAssetByOption); + } + + @override + Stream watchMultiUsersTimeline( + List userIds, + GroupAssetsBy groupAssetByOption, + ) { + final query = db.assets + .where() + .anyOf(userIds, (qb, userId) => qb.ownerIdEqualToAnyChecksum(userId)) + .filter() + .isArchivedEqualTo(false) + .isTrashedEqualTo(false) + .stackPrimaryAssetIdIsNull() + .sortByFileCreatedAtDesc(); + return _watchRenderList(query, groupAssetByOption); + } + + @override + Future getTimelineFromAssets( + List assets, + GroupAssetsBy getGroupByOption, + ) { + return RenderList.fromAssets(assets, getGroupByOption); + } + + @override + Stream watchAssetSelectionTimeline(int userId) { + final query = db.assets + .where() + .remoteIdIsNotNull() + .filter() + .ownerIdEqualTo(userId) + .isTrashedEqualTo(false) + .stackPrimaryAssetIdIsNull() + .sortByFileCreatedAtDesc(); + + return _watchRenderList(query, GroupAssetsBy.none); + } + + Stream _watchRenderList( + QueryBuilder query, + GroupAssetsBy groupAssetsBy, + ) async* { + yield await RenderList.fromQuery(query, groupAssetsBy); + await for (final _ in query.watchLazy()) { + yield await RenderList.fromQuery(query, groupAssetsBy); + } + } +} diff --git a/mobile/lib/repositories/user.repository.dart b/mobile/lib/repositories/user.repository.dart index fb4df84fe7..ea67b30e0d 100644 --- a/mobile/lib/repositories/user.repository.dart +++ b/mobile/lib/repositories/user.repository.dart @@ -1,4 +1,5 @@ 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/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/user.interface.dart'; @@ -25,13 +26,10 @@ class UserRepository extends DatabaseRepository implements IUserRepository { final int userId = Store.get(StoreKey.currentUser).isarId; final QueryBuilder afterWhere = self ? baseQuery.noOp() : baseQuery.isarIdNotEqualTo(userId); - final QueryBuilder query; - switch (sortBy) { - case null: - query = afterWhere.noOp(); - case UserSort.id: - query = afterWhere.sortById(); - } + final QueryBuilder query = switch (sortBy) { + null => afterWhere.noOp(), + UserSort.id => afterWhere.sortById(), + }; return query.findAll(); } @@ -60,4 +58,16 @@ class UserRepository extends DatabaseRepository implements IUserRepository { .or() .isarIdEqualTo(Store.get(StoreKey.currentUser).isarId) .findAll(); + + @override + Future getByDbId(int id) async { + return await db.users.get(id); + } + + @override + Future clearTable() async { + await txn(() async { + await db.users.clear(); + }); + } } diff --git a/mobile/lib/routing/auth_guard.dart b/mobile/lib/routing/auth_guard.dart index c99a890fc8..33eb8e81ad 100644 --- a/mobile/lib/routing/auth_guard.dart +++ b/mobile/lib/routing/auth_guard.dart @@ -1,8 +1,10 @@ import 'dart:io'; import 'package:auto_route/auto_route.dart'; -import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 3078a0dc1a..cd7a6f6b98 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -1,44 +1,50 @@ 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/log.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/logger_message.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/models/folder/recursive_folder.model.dart'; +import 'package:immich_mobile/pages/library/folder/folder.page.dart'; import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/models/shared_link/shared_link.model.dart'; import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart'; -import 'package:immich_mobile/pages/backup/album_preview.page.dart'; -import 'package:immich_mobile/pages/backup/backup_album_selection.page.dart'; -import 'package:immich_mobile/pages/backup/backup_controller.page.dart'; -import 'package:immich_mobile/pages/backup/backup_options.page.dart'; -import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart'; -import 'package:immich_mobile/pages/albums/albums.page.dart'; -import 'package:immich_mobile/pages/common/native_video_viewer.page.dart'; -import 'package:immich_mobile/pages/library/local_albums.page.dart'; -import 'package:immich_mobile/pages/library/people/people_collection.page.dart'; -import 'package:immich_mobile/pages/library/places/places_collection.page.dart'; -import 'package:immich_mobile/pages/library/library.page.dart'; -import 'package:immich_mobile/pages/common/activities.page.dart'; import 'package:immich_mobile/pages/album/album_additional_shared_user_selection.page.dart'; import 'package:immich_mobile/pages/album/album_asset_selection.page.dart'; import 'package:immich_mobile/pages/album/album_options.page.dart'; import 'package:immich_mobile/pages/album/album_shared_user_selection.page.dart'; import 'package:immich_mobile/pages/album/album_viewer.page.dart'; +import 'package:immich_mobile/pages/albums/albums.page.dart'; +import 'package:immich_mobile/pages/backup/album_preview.page.dart'; +import 'package:immich_mobile/pages/backup/backup_album_selection.page.dart'; +import 'package:immich_mobile/pages/backup/backup_controller.page.dart'; +import 'package:immich_mobile/pages/backup/backup_options.page.dart'; +import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart'; +import 'package:immich_mobile/pages/common/activities.page.dart'; import 'package:immich_mobile/pages/common/app_log.page.dart'; import 'package:immich_mobile/pages/common/app_log_detail.page.dart'; import 'package:immich_mobile/pages/common/create_album.page.dart'; import 'package:immich_mobile/pages/common/gallery_viewer.page.dart'; import 'package:immich_mobile/pages/common/headers_settings.page.dart'; +import 'package:immich_mobile/pages/common/native_video_viewer.page.dart'; import 'package:immich_mobile/pages/common/settings.page.dart'; import 'package:immich_mobile/pages/common/splash_screen.page.dart'; import 'package:immich_mobile/pages/common/tab_controller.page.dart'; -import 'package:immich_mobile/pages/editing/edit.page.dart'; import 'package:immich_mobile/pages/editing/crop.page.dart'; +import 'package:immich_mobile/pages/editing/edit.page.dart'; import 'package:immich_mobile/pages/editing/filter.page.dart'; import 'package:immich_mobile/pages/library/archive.page.dart'; import 'package:immich_mobile/pages/library/favorite.page.dart'; +import 'package:immich_mobile/pages/library/library.page.dart'; +import 'package:immich_mobile/pages/library/local_albums.page.dart'; +import 'package:immich_mobile/pages/library/partner/partner.page.dart'; +import 'package:immich_mobile/pages/library/partner/partner_detail.page.dart'; +import 'package:immich_mobile/pages/library/people/people_collection.page.dart'; +import 'package:immich_mobile/pages/library/places/places_collection.page.dart'; +import 'package:immich_mobile/pages/library/shared_link/shared_link.page.dart'; +import 'package:immich_mobile/pages/library/shared_link/shared_link_edit.page.dart'; import 'package:immich_mobile/pages/library/trash.page.dart'; import 'package:immich_mobile/pages/login/change_password.page.dart'; import 'package:immich_mobile/pages/login/login.page.dart'; @@ -54,10 +60,6 @@ import 'package:immich_mobile/pages/search/map/map_location_picker.page.dart'; import 'package:immich_mobile/pages/search/person_result.page.dart'; import 'package:immich_mobile/pages/search/recently_added.page.dart'; import 'package:immich_mobile/pages/search/search.page.dart'; -import 'package:immich_mobile/pages/library/partner/partner.page.dart'; -import 'package:immich_mobile/pages/library/partner/partner_detail.page.dart'; -import 'package:immich_mobile/pages/library/shared_link/shared_link.page.dart'; -import 'package:immich_mobile/pages/library/shared_link/shared_link_edit.page.dart'; import 'package:immich_mobile/pages/share_intent/share_intent.page.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; @@ -67,7 +69,6 @@ import 'package:immich_mobile/routing/custom_transition_builders.dart'; import 'package:immich_mobile/routing/duplicate_guard.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; -import 'package:isar/isar.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; part 'router.gr.dart'; @@ -208,6 +209,11 @@ class AppRouter extends RootStackRouter { guards: [_authGuard, _duplicateGuard], transitionsBuilder: TransitionsBuilders.slideLeft, ), + CustomRoute( + page: FolderRoute.page, + guards: [_authGuard], + transitionsBuilder: TransitionsBuilders.fadeIn, + ), AutoRoute( page: PartnerDetailRoute.page, guards: [_authGuard, _duplicateGuard], diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 48528fdfe2..f2f169247e 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -83,7 +83,6 @@ class AlbumAssetSelectionRoute Key? key, required Set existingAssets, bool canDeselect = false, - required QueryBuilder? query, List? children, }) : super( AlbumAssetSelectionRoute.name, @@ -91,7 +90,6 @@ class AlbumAssetSelectionRoute key: key, existingAssets: existingAssets, canDeselect: canDeselect, - query: query, ), initialChildren: children, ); @@ -106,7 +104,6 @@ class AlbumAssetSelectionRoute key: args.key, existingAssets: args.existingAssets, canDeselect: args.canDeselect, - query: args.query, ); }, ); @@ -117,7 +114,6 @@ class AlbumAssetSelectionRouteArgs { this.key, required this.existingAssets, this.canDeselect = false, - required this.query, }); final Key? key; @@ -126,11 +122,9 @@ class AlbumAssetSelectionRouteArgs { final bool canDeselect; - final QueryBuilder? query; - @override String toString() { - return 'AlbumAssetSelectionRouteArgs{key: $key, existingAssets: $existingAssets, canDeselect: $canDeselect, query: $query}'; + return 'AlbumAssetSelectionRouteArgs{key: $key, existingAssets: $existingAssets, canDeselect: $canDeselect}'; } } @@ -392,7 +386,7 @@ class AllVideosRoute extends PageRouteInfo { class AppLogDetailRoute extends PageRouteInfo { AppLogDetailRoute({ Key? key, - required LoggerMessage logMessage, + required LogMessage logMessage, List? children, }) : super( AppLogDetailRoute.name, @@ -425,7 +419,7 @@ class AppLogDetailRouteArgs { final Key? key; - final LoggerMessage logMessage; + final LogMessage logMessage; @override String toString() { @@ -794,6 +788,53 @@ class FilterImageRouteArgs { } } +/// generated route for +/// [FolderPage] +class FolderRoute extends PageRouteInfo { + FolderRoute({ + Key? key, + RecursiveFolder? folder, + List? children, + }) : super( + FolderRoute.name, + args: FolderRouteArgs( + key: key, + folder: folder, + ), + initialChildren: children, + ); + + static const String name = 'FolderRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = + data.argsAs(orElse: () => const FolderRouteArgs()); + return FolderPage( + key: args.key, + folder: args.folder, + ); + }, + ); +} + +class FolderRouteArgs { + const FolderRouteArgs({ + this.key, + this.folder, + }); + + final Key? key; + + final RecursiveFolder? folder; + + @override + String toString() { + return 'FolderRouteArgs{key: $key, folder: $folder}'; + } +} + /// generated route for /// [GalleryViewerPage] class GalleryViewerRoute extends PageRouteInfo { @@ -1060,6 +1101,7 @@ class NativeVideoViewerRoute extends PageRouteInfo { required Asset asset, required Widget image, bool showControls = true, + int playbackDelayFactor = 1, List? children, }) : super( NativeVideoViewerRoute.name, @@ -1068,6 +1110,7 @@ class NativeVideoViewerRoute extends PageRouteInfo { asset: asset, image: image, showControls: showControls, + playbackDelayFactor: playbackDelayFactor, ), initialChildren: children, ); @@ -1083,6 +1126,7 @@ class NativeVideoViewerRoute extends PageRouteInfo { asset: args.asset, image: args.image, showControls: args.showControls, + playbackDelayFactor: args.playbackDelayFactor, ); }, ); @@ -1094,6 +1138,7 @@ class NativeVideoViewerRouteArgs { required this.asset, required this.image, this.showControls = true, + this.playbackDelayFactor = 1, }); final Key? key; @@ -1104,9 +1149,11 @@ class NativeVideoViewerRouteArgs { final bool showControls; + final int playbackDelayFactor; + @override String toString() { - return 'NativeVideoViewerRouteArgs{key: $key, asset: $asset, image: $image, showControls: $showControls}'; + return 'NativeVideoViewerRouteArgs{key: $key, asset: $asset, image: $image, showControls: $showControls, playbackDelayFactor: $playbackDelayFactor}'; } } diff --git a/mobile/lib/routing/tab_navigation_observer.dart b/mobile/lib/routing/tab_navigation_observer.dart index 7d96b83d02..b6a845a0b3 100644 --- a/mobile/lib/routing/tab_navigation_observer.dart +++ b/mobile/lib/routing/tab_navigation_observer.dart @@ -1,12 +1,12 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/memory.provider.dart'; - +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; +import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; class TabNavigationObserver extends AutoRouterObserver { @@ -37,7 +37,7 @@ class TabNavigationObserver extends AutoRouterObserver { return; } - Store.put( + await Store.put( StoreKey.currentUser, User.fromUserDto(userResponseDto, userPreferences), ); diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index a993705e11..3a44ca7286 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -6,23 +6,24 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/album.interface.dart'; import 'package:immich_mobile/interfaces/album_api.interface.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; -import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/backup_album.interface.dart'; import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart'; -import 'package:immich_mobile/entities/backup_album.entity.dart'; -import 'package:immich_mobile/entities/album.entity.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/repositories/album.repository.dart'; import 'package:immich_mobile/repositories/album_api.repository.dart'; +import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/backup.repository.dart'; -import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; @@ -35,7 +36,7 @@ final albumServiceProvider = Provider( ref.watch(entityServiceProvider), ref.watch(albumRepositoryProvider), ref.watch(assetRepositoryProvider), - ref.watch(backupRepositoryProvider), + ref.watch(backupAlbumRepositoryProvider), ref.watch(albumMediaRepositoryProvider), ref.watch(albumApiRepositoryProvider), ), @@ -47,7 +48,7 @@ class AlbumService { final EntityService _entityService; final IAlbumRepository _albumRepository; final IAssetRepository _assetRepository; - final IBackupRepository _backupAlbumRepository; + final IBackupAlbumRepository _backupAlbumRepository; final IAlbumMediaRepository _albumMediaRepository; final IAlbumApiRepository _albumApiRepository; final Logger _log = Logger('AlbumService'); @@ -168,7 +169,10 @@ class AlbumService { final Stopwatch sw = Stopwatch()..start(); bool changes = false; try { - await _userService.refreshUsers(); + final users = await _userService.getUsersFromServer(); + if (users != null) { + await _syncService.syncUsersFromServer(users); + } final (sharedAlbum, ownedAlbum) = await ( // Note: `shared: true` is required to get albums that don't belong to // us due to unusual behaviour on the API but this will also return our @@ -309,7 +313,7 @@ class AlbumService { final List idsToRemove = _syncService.sharedAssetsToRemove(foreignAssets, existing); if (idsToRemove.isNotEmpty) { - await _assetRepository.deleteById(idsToRemove); + await _assetRepository.deleteByIds(idsToRemove); } } else { await _albumRepository.delete(album.id); @@ -442,10 +446,31 @@ class AlbumService { } } - Future> getAll() async { + Future> getAllRemoteAlbums() async { return _albumRepository.getAll(remote: true); } + Future> getAllLocalAlbums() async { + return _albumRepository.getAll(remote: false); + } + + Stream> watchRemoteAlbums() { + return _albumRepository.watchRemoteAlbums(); + } + + Stream> watchLocalAlbums() { + return _albumRepository.watchLocalAlbums(); + } + + /// Get album by Isar ID + Future getAlbumById(int id) { + return _albumRepository.get(id); + } + + Stream watchAlbum(int id) { + return _albumRepository.watchAlbum(id); + } + Future> search( String searchTerm, QuickFilterMode filterMode, @@ -465,4 +490,8 @@ class AlbumService { } return null; } + + Future clearTable() async { + await _albumRepository.clearTable(); + } } diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 0f6fe8a100..0ef68e1c41 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -4,11 +4,12 @@ import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; +import 'package:http/http.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/utils/url_helper.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; -import 'package:http/http.dart'; class ApiService implements Authentication { late ApiClient _apiClient; @@ -23,7 +24,6 @@ class ApiService implements Authentication { late MapApi mapApi; late PartnersApi partnersApi; late PeopleApi peopleApi; - late AuditApi auditApi; late SharedLinksApi sharedLinksApi; late SyncApi syncApi; late SystemConfigApi systemConfigApi; @@ -31,6 +31,8 @@ class ApiService implements Authentication { late DownloadApi downloadApi; late TrashApi trashApi; late StacksApi stacksApi; + late ViewApi viewApi; + late MemoriesApi memoriesApi; ApiService() { final endpoint = Store.tryGet(StoreKey.serverEndpoint); @@ -56,7 +58,6 @@ class ApiService implements Authentication { mapApi = MapApi(_apiClient); partnersApi = PartnersApi(_apiClient); peopleApi = PeopleApi(_apiClient); - auditApi = AuditApi(_apiClient); sharedLinksApi = SharedLinksApi(_apiClient); syncApi = SyncApi(_apiClient); systemConfigApi = SystemConfigApi(_apiClient); @@ -64,6 +65,8 @@ class ApiService implements Authentication { downloadApi = DownloadApi(_apiClient); trashApi = TrashApi(_apiClient); stacksApi = StacksApi(_apiClient); + viewApi = ViewApi(_apiClient); + memoriesApi = MemoriesApi(_apiClient); } Future resolveAndSetEndpoint(String serverUrl) async { @@ -83,15 +86,17 @@ class ApiService implements Authentication { /// port - optional (default: based on schema) /// path - optional Future resolveEndpoint(String serverUrl) async { - final url = sanitizeUrl(serverUrl); - - if (!await _isEndpointAvailable(serverUrl)) { - throw ApiException(503, "Server is not reachable"); - } + String url = sanitizeUrl(serverUrl); // Check for /.well-known/immich final wellKnownEndpoint = await _getWellKnownEndpoint(url); - if (wellKnownEndpoint.isNotEmpty) return wellKnownEndpoint; + if (wellKnownEndpoint.isNotEmpty) { + url = sanitizeUrl(wellKnownEndpoint); + } + + if (!await _isEndpointAvailable(url)) { + throw ApiException(503, "Server is not reachable"); + } // Otherwise, assume the URL provided is the api endpoint return url; @@ -127,10 +132,12 @@ class ApiService implements Authentication { var headers = {"Accept": "application/json"}; headers.addAll(getRequestHeaders()); - final res = await client.get( - Uri.parse("$baseUrl/.well-known/immich"), - headers: headers, - ); + final res = await client + .get( + Uri.parse("$baseUrl/.well-known/immich"), + headers: headers, + ) + .timeout(const Duration(seconds: 5)); if (res.statusCode == 200) { final data = jsonDecode(res.body); @@ -149,9 +156,9 @@ class ApiService implements Authentication { return ""; } - void setAccessToken(String accessToken) { + Future setAccessToken(String accessToken) async { _accessToken = accessToken; - Store.put(StoreKey.accessToken, accessToken); + await Store.put(StoreKey.accessToken, accessToken); } Future setDeviceInfoHeader() async { diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index 19e7093faf..1870a61d7a 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -1,4 +1,5 @@ import 'package:immich_mobile/constants/colors.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; enum AppSettingsEnum { diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index 7d27d1b27b..815962efac 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -4,22 +4,24 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/interfaces/exif.interface.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset_api.interface.dart'; -import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/asset_media.interface.dart'; +import 'package:immich_mobile/interfaces/backup_album.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart'; -import 'package:immich_mobile/interfaces/exif_info.interface.dart'; import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/exif.provider.dart'; import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/etag.repository.dart'; -import 'package:immich_mobile/repositories/exif_info.repository.dart'; import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/api.service.dart'; @@ -34,15 +36,16 @@ final assetServiceProvider = Provider( (ref) => AssetService( ref.watch(assetApiRepositoryProvider), ref.watch(assetRepositoryProvider), - ref.watch(exifInfoRepositoryProvider), + ref.watch(exifRepositoryProvider), ref.watch(userRepositoryProvider), ref.watch(etagRepositoryProvider), - ref.watch(backupRepositoryProvider), + ref.watch(backupAlbumRepositoryProvider), ref.watch(apiServiceProvider), ref.watch(syncServiceProvider), ref.watch(userServiceProvider), ref.watch(backupServiceProvider), ref.watch(albumServiceProvider), + ref.watch(assetMediaRepositoryProvider), ), ); @@ -52,12 +55,13 @@ class AssetService { final IExifInfoRepository _exifInfoRepository; final IUserRepository _userRepository; final IETagRepository _etagRepository; - final IBackupRepository _backupRepository; + final IBackupAlbumRepository _backupRepository; final ApiService _apiService; final SyncService _syncService; final UserService _userService; final BackupService _backupService; final AlbumService _albumService; + final IAssetMediaRepository _assetMediaRepository; final log = Logger('AssetService'); AssetService( @@ -72,6 +76,7 @@ class AssetService { this._userService, this._backupService, this._albumService, + this._assetMediaRepository, ); /// Checks the server for updated assets and updates the local database if @@ -158,44 +163,21 @@ class AssetService { } } - Future deleteAssets( - Iterable deleteAssets, { - bool? force = false, - }) async { - try { - final List payload = []; - - for (final asset in deleteAssets) { - payload.add(asset.remoteId!); - } - - await _apiService.assetsApi.deleteAssets( - AssetBulkDeleteDto( - ids: payload, - force: force, - ), - ); - return true; - } catch (error, stack) { - log.severe("Error while deleting assets", error, stack); - } - return false; - } - /// Loads the exif information from the database. If there is none, loads /// the exif info from the server (remote assets only) Future loadExif(Asset a) async { - a.exifInfo ??= await _exifInfoRepository.get(a.id); + a.exifInfo ??= (await _exifInfoRepository.get(a.id)); // fileSize is always filled on the server but not set on client if (a.exifInfo?.fileSize == null) { if (a.isRemote) { final dto = await _apiService.assetsApi.getAssetInfo(a.remoteId!); if (dto != null && dto.exifInfo != null) { - final newExif = Asset.remote(dto).exifInfo!.copyWith(id: a.id); + final newExif = Asset.remote(dto).exifInfo!.copyWith(assetId: a.id); a.exifInfo = newExif; if (newExif != a.exifInfo) { if (a.isInDb) { - _assetRepository.transaction(() => _assetRepository.update(a)); + await _assetRepository + .transaction(() => _assetRepository.update(a)); } else { debugPrint("[loadExif] parameter Asset is not from DB!"); } @@ -276,7 +258,8 @@ class AssetService { for (var element in assets) { element.fileCreatedAt = DateTime.parse(updatedDt); - element.exifInfo?.dateTimeOriginal = DateTime.parse(updatedDt); + element.exifInfo ??= element.exifInfo + ?.copyWith(dateTimeOriginal: DateTime.parse(updatedDt)); } await _syncService.upsertAssetsWithExif(assets); @@ -302,8 +285,10 @@ class AssetService { ); for (var element in assets) { - element.exifInfo?.lat = location.latitude; - element.exifInfo?.long = location.longitude; + element.exifInfo ??= element.exifInfo?.copyWith( + latitude: location.latitude, + longitude: location.longitude, + ); } await _syncService.upsertAssetsWithExif(assets); @@ -367,7 +352,7 @@ class AssetService { String newDescription, ) async { final remoteAssetId = asset.remoteId; - final localExifId = asset.exifInfo?.id; + final localExifId = asset.exifInfo?.assetId; // Guard [remoteAssetId] and [localExifId] null if (remoteAssetId == null || localExifId == null) { @@ -385,14 +370,14 @@ class AssetService { var exifInfo = await _exifInfoRepository.get(localExifId); if (exifInfo != null) { - exifInfo.description = description; - await _exifInfoRepository.update(exifInfo); + await _exifInfoRepository + .update(exifInfo.copyWith(description: description)); } } } Future getDescription(Asset asset) async { - final localExifId = asset.exifInfo?.id; + final localExifId = asset.exifInfo?.assetId; // Guard [remoteAssetId] and [localExifId] null if (localExifId == null) { @@ -428,4 +413,119 @@ class AssetService { return 1.0; } + + Future> getStackAssets(String stackId) { + return _assetRepository.getStackAssets(stackId); + } + + Future clearTable() { + return _assetRepository.clearTable(); + } + + /// Delete assets from local file system and unreference from the database + Future deleteLocalAssets(Iterable assets) async { + // Delete files from local gallery + final candidates = assets.where((asset) => asset.isLocal); + + final deletedIds = await _assetMediaRepository + .deleteAll(candidates.map((asset) => asset.localId!).toList()); + + // Modify local database by removing the reference to the local assets + if (deletedIds.isNotEmpty) { + // Delete records from local database + final isarIds = assets + .where((asset) => asset.storage == AssetState.local) + .map((asset) => asset.id) + .toList(); + await _assetRepository.deleteByIds(isarIds); + + // Modify Merged asset to be remote only + final updatedAssets = assets + .where((asset) => asset.storage == AssetState.merged) + .map((asset) { + asset.localId = null; + return asset; + }).toList(); + + await _assetRepository.updateAll(updatedAssets); + } + } + + /// Delete assets from the server and unreference from the database + Future deleteRemoteAssets( + Iterable assets, { + bool shouldDeletePermanently = false, + }) async { + final candidates = assets.where((a) => a.isRemote); + if (candidates.isEmpty) { + return; + } + + await _apiService.assetsApi.deleteAssets( + AssetBulkDeleteDto( + ids: candidates.map((a) => a.remoteId!).toList(), + force: shouldDeletePermanently, + ), + ); + + /// Update asset info bassed on the deletion type. + final payload = shouldDeletePermanently + ? assets + .where((asset) => asset.storage == AssetState.merged) + .map((asset) { + asset.remoteId = null; + return asset; + }) + : assets.where((asset) => asset.isRemote).map((asset) { + asset.isTrashed = true; + return asset; + }); + + await _assetRepository.transaction(() async { + await _assetRepository.updateAll(payload.toList()); + + if (shouldDeletePermanently) { + final remoteAssetIds = assets + .where((asset) => asset.storage == AssetState.remote) + .map((asset) => asset.id) + .toList(); + await _assetRepository.deleteByIds(remoteAssetIds); + } + }); + } + + /// Delete assets on both local file system and the server. + /// Unreference from the database. + Future deleteAssets( + Iterable assets, { + bool shouldDeletePermanently = false, + }) async { + final hasLocal = assets.any((asset) => asset.isLocal); + final hasRemote = assets.any((asset) => asset.isRemote); + + if (hasLocal) { + await deleteLocalAssets(assets); + } + + if (hasRemote) { + await deleteRemoteAssets( + assets, + shouldDeletePermanently: shouldDeletePermanently, + ); + } + } + + Stream watchAsset(int id, {bool fireImmediately = false}) { + return _assetRepository.watchAsset(id, fireImmediately: fireImmediately); + } + + Future> getRecentlyAddedAssets() async { + final me = await _userRepository.me(); + return _assetRepository.getRecentlyAddedAssets(me.isarId); + } + + Future> getMotionAssets() async { + final me = await _userRepository.me(); + return _assetRepository.getMotionAssets(me.isarId); + } } diff --git a/mobile/lib/services/auth.service.dart b/mobile/lib/services/auth.service.dart index 08741a15db..20fa62dc4b 100644 --- a/mobile/lib/services/auth.service.dart +++ b/mobile/lib/services/auth.service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; 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/interfaces/auth.interface.dart'; import 'package:immich_mobile/interfaces/auth_api.interface.dart'; @@ -74,7 +75,7 @@ class AuthService { isValid = true; } } catch (error) { - _log.severe("Error validating auxilary endpoint", error); + _log.severe("Error validating auxiliary endpoint", error); } finally { httpclient.close(); } @@ -186,7 +187,7 @@ class AuthService { _log.severe("Cannot resolve endpoint", error); continue; } catch (_) { - _log.severe("Auxilary server is not valid"); + _log.severe("Auxiliary server is not valid"); continue; } } diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index c059f48f0e..fe483384e5 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -3,6 +3,7 @@ import 'dart:developer'; import 'dart:io'; import 'dart:isolate'; import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities; + import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -10,20 +11,25 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/interfaces/backup.interface.dart'; -import 'package:immich_mobile/main.dart'; +import 'package:immich_mobile/domain/interfaces/exif.interface.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/exif.repository.dart'; +import 'package:immich_mobile/interfaces/backup_album.interface.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; +import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; +import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/repositories/album.repository.dart'; import 'package:immich_mobile/repositories/album_api.repository.dart'; +import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/auth.repository.dart'; import 'package:immich_mobile/repositories/auth_api.repository.dart'; import 'package:immich_mobile/repositories/backup.repository.dart'; -import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/repositories/etag.repository.dart'; -import 'package:immich_mobile/repositories/exif_info.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/repositories/network.repository.dart'; import 'package:immich_mobile/repositories/partner_api.repository.dart'; @@ -31,25 +37,22 @@ import 'package:immich_mobile/repositories/permission.repository.dart'; import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/repositories/user_api.repository.dart'; import 'package:immich_mobile/services/album.service.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/backup.service.dart'; import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/services/localization.service.dart'; -import 'package:immich_mobile/entities/backup_album.entity.dart'; -import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; -import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; -import 'package:immich_mobile/services/backup.service.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/network.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; +import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; import 'package:network_info_plus/network_info_plus.dart'; -import 'package:path_provider_ios/path_provider_ios.dart'; +import 'package:path_provider_foundation/path_provider_foundation.dart'; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; final backgroundServiceProvider = Provider( @@ -319,7 +322,7 @@ class BackgroundService { // NOTE: I'm not sure this is strictly necessary anymore, but // out of an abundance of caution, we will keep it in until someone // can say for sure - PathProviderIOS.registerWith(); + PathProviderFoundation.registerWith(); } switch (call.method) { case "backgroundProcessing": @@ -327,7 +330,7 @@ class BackgroundService { try { _clearErrorNotifications(); - // iOS should time out after some threshhold so it doesn't wait + // iOS should time out after some threshold so it doesn't wait // indefinitely and can run later // Android is fine to wait here until the lock releases final waitForLock = Platform.isIOS @@ -367,7 +370,8 @@ class BackgroundService { } Future _onAssetsChanged() async { - final db = await loadDb(); + final db = await Bootstrap.initIsar(); + await Bootstrap.initDomain(db); HttpOverrides.global = HttpSSLCertOverride(); ApiService apiService = ApiService(); @@ -375,8 +379,8 @@ class BackgroundService { AppSettingsService settingsService = AppSettingsService(); AlbumRepository albumRepository = AlbumRepository(db); AssetRepository assetRepository = AssetRepository(db); - BackupRepository backupRepository = BackupRepository(db); - ExifInfoRepository exifInfoRepository = ExifInfoRepository(db); + BackupAlbumRepository backupRepository = BackupAlbumRepository(db); + IExifInfoRepository exifInfoRepository = IsarExifRepository(db); ETagRepository eTagRepository = ETagRepository(db); AlbumMediaRepository albumMediaRepository = AlbumMediaRepository(); FileMediaRepository fileMediaRepository = FileMediaRepository(); @@ -407,7 +411,6 @@ class BackgroundService { partnerApiRepository, userApiRepository, userRepository, - syncSerive, ); AlbumService albumService = AlbumService( userService, @@ -717,7 +720,6 @@ enum IosBackgroundTask { fetch, processing } /// entry point called by Kotlin/Java code; needs to be a top-level function @pragma('vm:entry-point') void _nativeEntry() { - HttpOverrides.global = HttpSSLCertOverride(); WidgetsFlutterBinding.ensureInitialized(); DartPluginRegistrant.ensureInitialized(); BackgroundService backgroundService = BackgroundService(); diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index 7bce1047e2..a6468f249b 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -6,6 +6,7 @@ import 'package:cancellation_token_http/http.dart' as http; import 'package:collection/collection.dart'; 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/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; @@ -519,18 +520,12 @@ class BackupService { return responseBody.containsKey('id') ? responseBody['id'] : null; } - String _getAssetType(AssetType assetType) { - switch (assetType) { - case AssetType.audio: - return "AUDIO"; - case AssetType.image: - return "IMAGE"; - case AssetType.video: - return "VIDEO"; - case AssetType.other: - return "OTHER"; - } - } + String _getAssetType(AssetType assetType) => switch (assetType) { + AssetType.audio => "AUDIO", + AssetType.image => "IMAGE", + AssetType.video => "VIDEO", + AssetType.other => "OTHER", + }; } class MultipartRequest extends http.MultipartRequest { diff --git a/mobile/lib/services/backup_album.service.dart b/mobile/lib/services/backup_album.service.dart new file mode 100644 index 0000000000..8030d66937 --- /dev/null +++ b/mobile/lib/services/backup_album.service.dart @@ -0,0 +1,34 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/interfaces/backup_album.interface.dart'; +import 'package:immich_mobile/repositories/backup.repository.dart'; + +final backupAlbumServiceProvider = Provider((ref) { + return BackupAlbumService(ref.watch(backupAlbumRepositoryProvider)); +}); + +class BackupAlbumService { + final IBackupAlbumRepository _backupAlbumRepository; + + BackupAlbumService(this._backupAlbumRepository); + + Future> getAll({BackupAlbumSort? sort}) { + return _backupAlbumRepository.getAll(sort: sort); + } + + Future> getIdsBySelection(BackupSelection backup) { + return _backupAlbumRepository.getIdsBySelection(backup); + } + + Future> getAllBySelection(BackupSelection backup) { + return _backupAlbumRepository.getAllBySelection(backup); + } + + Future deleteAll(List ids) { + return _backupAlbumRepository.deleteAll(ids); + } + + Future updateAll(List backupAlbums) { + return _backupAlbumRepository.updateAll(backupAlbums); + } +} diff --git a/mobile/lib/services/backup_verification.service.dart b/mobile/lib/services/backup_verification.service.dart index 82cfb8347a..c2e93a678a 100644 --- a/mobile/lib/services/backup_verification.service.dart +++ b/mobile/lib/services/backup_verification.service.dart @@ -5,16 +5,19 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/interfaces/exif.interface.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; -import 'package:immich_mobile/interfaces/exif_info.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; +import 'package:immich_mobile/providers/infrastructure/exif.provider.dart'; import 'package:immich_mobile/repositories/asset.repository.dart'; -import 'package:immich_mobile/repositories/exif_info.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/diff.dart'; /// Finds duplicates originating from missing EXIF information @@ -122,6 +125,8 @@ class BackupVerificationService { assert(tuple.deleteCandidates.length == tuple.originals.length); final List result = []; BackgroundIsolateBinaryMessenger.ensureInitialized(tuple.rootIsolateToken); + final db = await Bootstrap.initIsar(); + await Bootstrap.initDomain(db); await tuple.fileMediaRepository.enableBackgroundAccess(); final ApiService apiService = ApiService(); apiService.setEndpoint(tuple.endpoint); @@ -145,11 +150,11 @@ class BackupVerificationService { ) async { if (remote.checksum == local.checksum) return false; ExifInfo? exif = remote.exifInfo; - if (exif != null && exif.lat != null) return false; + if (exif != null && exif.latitude != null) return false; if (exif == null || exif.fileSize == null) { final dto = await apiService.assetsApi.getAssetInfo(remote.remoteId!); if (dto != null && dto.exifInfo != null) { - exif = ExifInfo.fromDto(dto.exifInfo!); + exif = ExifDtoConverter.fromDto(dto.exifInfo!); } } final file = await local.local!.originFile; @@ -158,7 +163,7 @@ class BackupVerificationService { if (exif.fileSize! == origSize || exif.fileSize! != origSize) { final latLng = await local.local!.latlngAsync(); - if (exif.lat == null && + if (exif.latitude == null && latLng.latitude != null && (remote.fileCreatedAt.isAtSameMomentAs(local.fileCreatedAt) || remote.fileModifiedAt.isAtSameMomentAs(local.fileModifiedAt) || @@ -211,6 +216,6 @@ final backupVerificationServiceProvider = Provider( (ref) => BackupVerificationService( ref.watch(fileMediaRepositoryProvider), ref.watch(assetRepositoryProvider), - ref.watch(exifInfoRepositoryProvider), + ref.watch(exifRepositoryProvider), ), ); diff --git a/mobile/lib/services/device.service.dart b/mobile/lib/services/device.service.dart index e1676d5683..393274608b 100644 --- a/mobile/lib/services/device.service.dart +++ b/mobile/lib/services/device.service.dart @@ -1,5 +1,6 @@ import 'package:flutter_udid/flutter_udid.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'; final deviceServiceProvider = Provider((ref) => DeviceService()); diff --git a/mobile/lib/services/download.service.dart b/mobile/lib/services/download.service.dart index 7cf6f309e9..45297853f6 100644 --- a/mobile/lib/services/download.service.dart +++ b/mobile/lib/services/download.service.dart @@ -3,8 +3,9 @@ import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/interfaces/download.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; diff --git a/mobile/lib/services/etag.service.dart b/mobile/lib/services/etag.service.dart new file mode 100644 index 0000000000..6dd8a76bb3 --- /dev/null +++ b/mobile/lib/services/etag.service.dart @@ -0,0 +1,16 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/etag.interface.dart'; +import 'package:immich_mobile/repositories/etag.repository.dart'; + +final etagServiceProvider = + Provider((ref) => ETagService(ref.watch(etagRepositoryProvider))); + +class ETagService { + final IETagRepository _eTagRepository; + + ETagService(this._eTagRepository); + + Future clearTable() { + return _eTagRepository.clearTable(); + } +} diff --git a/mobile/lib/services/exif.service.dart b/mobile/lib/services/exif.service.dart new file mode 100644 index 0000000000..973f04303e --- /dev/null +++ b/mobile/lib/services/exif.service.dart @@ -0,0 +1,16 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/interfaces/exif.interface.dart'; +import 'package:immich_mobile/providers/infrastructure/exif.provider.dart'; + +final exifServiceProvider = + Provider((ref) => ExifService(ref.watch(exifRepositoryProvider))); + +class ExifService { + final IExifInfoRepository _exifInfoRepository; + + const ExifService(this._exifInfoRepository); + + Future clearTable() { + return _exifInfoRepository.deleteAll(); + } +} diff --git a/mobile/lib/services/folder.service.dart b/mobile/lib/services/folder.service.dart new file mode 100644 index 0000000000..5b97b475b2 --- /dev/null +++ b/mobile/lib/services/folder.service.dart @@ -0,0 +1,132 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/entities/asset.entity.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/repositories/folder_api.repository.dart'; +import 'package:logging/logging.dart'; + +final folderServiceProvider = Provider( + (ref) => FolderService(ref.watch(folderApiRepositoryProvider)), +); + +class FolderService { + final FolderApiRepository _folderApiRepository; + final Logger _log = Logger("FolderService"); + + FolderService(this._folderApiRepository); + + Future getFolderStructure(SortOrder order) async { + final paths = await _folderApiRepository.getAllUniquePaths(); + + // Create folder structure + Map> folderMap = {}; + + for (String fullPath in paths) { + if (fullPath == '/') continue; + + // Ensure the path starts with a slash + if (!fullPath.startsWith('/')) { + fullPath = '/$fullPath'; + } + + List segments = fullPath.split('/') + ..removeWhere((s) => s.isEmpty); + + String currentPath = ''; + + for (int i = 0; i < segments.length; i++) { + String parentPath = currentPath.isEmpty ? '_root_' : currentPath; + currentPath = + i == 0 ? '/${segments[i]}' : '$currentPath/${segments[i]}'; + + if (!folderMap.containsKey(parentPath)) { + folderMap[parentPath] = []; + } + + if (!folderMap[parentPath]!.any((f) => f.name == segments[i])) { + folderMap[parentPath]!.add( + RecursiveFolder( + path: parentPath == '_root_' ? '' : parentPath, + name: segments[i], + subfolders: [], + ), + ); + // Sort folders based on order parameter + folderMap[parentPath]!.sort( + (a, b) => order == SortOrder.desc + ? b.name.compareTo(a.name) + : a.name.compareTo(b.name), + ); + } + } + } + + void attachSubfolders(RecursiveFolder folder) { + String fullPath = folder.path.isEmpty + ? '/${folder.name}' + : '${folder.path}/${folder.name}'; + + if (folderMap.containsKey(fullPath)) { + folder.subfolders.addAll(folderMap[fullPath]!); + // Sort subfolders based on order parameter + folder.subfolders.sort( + (a, b) => order == SortOrder.desc + ? b.name.compareTo(a.name) + : a.name.compareTo(b.name), + ); + for (var subfolder in folder.subfolders) { + attachSubfolders(subfolder); + } + } + } + + List rootSubfolders = folderMap['_root_'] ?? []; + // Sort root subfolders based on order parameter + rootSubfolders.sort( + (a, b) => order == SortOrder.desc + ? b.name.compareTo(a.name) + : a.name.compareTo(b.name), + ); + + for (var folder in rootSubfolders) { + attachSubfolders(folder); + } + + return RootFolder( + subfolders: rootSubfolders, + path: '/', + ); + } + + Future> getFolderAssets( + RootFolder folder, + SortOrder order, + ) async { + try { + if (folder is RecursiveFolder) { + String fullPath = + folder.path.isEmpty ? folder.name : '${folder.path}/${folder.name}'; + fullPath = fullPath[0] == '/' ? fullPath.substring(1) : fullPath; + var result = await _folderApiRepository.getAssetsForPath(fullPath); + + if (order == SortOrder.desc) { + result.sort((a, b) => b.fileCreatedAt.compareTo(a.fileCreatedAt)); + } else { + result.sort((a, b) => a.fileCreatedAt.compareTo(b.fileCreatedAt)); + } + + return result; + } + final result = await _folderApiRepository.getAssetsForPath('/'); + return result; + } catch (e, stack) { + _log.severe( + "Failed to fetch assets for folder ${folder is RecursiveFolder ? folder.name : "root"}", + e, + stack, + ); + return []; + } + } +} diff --git a/mobile/lib/services/immich_logger.service.dart b/mobile/lib/services/immich_logger.service.dart index c46fdc21d7..fab4b9966a 100644 --- a/mobile/lib/services/immich_logger.service.dart +++ b/mobile/lib/services/immich_logger.service.dart @@ -2,10 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/widgets.dart'; -import 'package:immich_mobile/entities/logger_message.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:isar/isar.dart'; -import 'package:logging/logging.dart'; +import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; @@ -17,75 +14,10 @@ import 'package:share_plus/share_plus.dart'; /// /// Logs can be shared by calling the `shareLogs` method, which will open a share dialog /// and generate a csv file. -class ImmichLogger { - static final ImmichLogger _instance = ImmichLogger._internal(); - final maxLogEntries = 500; - final Isar _db = Isar.getInstance()!; - List _msgBuffer = []; - Timer? _timer; +abstract final class ImmichLogger { + const ImmichLogger(); - factory ImmichLogger() => _instance; - - ImmichLogger._internal() { - _removeOverflowMessages(); - final int levelId = Store.get(StoreKey.logLevel, 5); // 5 is INFO - Logger.root.level = Level.LEVELS[levelId]; - Logger.root.onRecord.listen(_writeLogToDatabase); - } - - set level(Level level) => Logger.root.level = level; - - List get messages { - final inDb = - _db.loggerMessages.where(sort: Sort.desc).anyId().findAllSync(); - return _msgBuffer.isEmpty ? inDb : _msgBuffer.reversed.toList() + inDb; - } - - void _removeOverflowMessages() { - final msgCount = _db.loggerMessages.countSync(); - if (msgCount > maxLogEntries) { - final numberOfEntryToBeDeleted = msgCount - maxLogEntries; - _db.writeTxn( - () => _db.loggerMessages - .where() - .limit(numberOfEntryToBeDeleted) - .deleteAll(), - ); - } - } - - void _writeLogToDatabase(LogRecord record) { - debugPrint('[${record.level.name}] [${record.time}] ${record.message}'); - final lm = LoggerMessage( - message: record.message, - details: record.error?.toString(), - level: record.level.toLogLevel(), - createdAt: record.time, - context1: record.loggerName, - context2: record.stackTrace?.toString(), - ); - _msgBuffer.add(lm); - - // delayed batch writing to database: increases performance when logging - // messages in quick succession and reduces NAND wear - _timer ??= Timer(const Duration(seconds: 5), _flushBufferToDatabase); - } - - void _flushBufferToDatabase() { - _timer = null; - final buffer = _msgBuffer; - _msgBuffer = []; - _db.writeTxn(() => _db.loggerMessages.putAll(buffer)); - } - - void clearLogs() { - _timer?.cancel(); - _timer = null; - _msgBuffer.clear(); - _db.writeTxn(() => _db.loggerMessages.clear()); - } - - Future shareLogs(BuildContext context) async { + static Future shareLogs(BuildContext context) async { final tempDir = await getTemporaryDirectory(); final dateTime = DateTime.now().toIso8601String(); final filePath = '${tempDir.path}/Immich_log_$dateTime.log'; @@ -93,13 +25,13 @@ class ImmichLogger { final io = logFile.openWrite(); try { // Write messages - for (final m in messages) { + for (final m in await LogService.I.getMessages()) { final created = m.createdAt; final level = m.level.name.padRight(8); - final logger = (m.context1 ?? "").padRight(20); + final logger = (m.logger ?? "").padRight(20); final message = m.message; - final error = m.details != null ? " ${m.details} |" : ""; - final stack = m.context2 != null ? "\n${m.context2!}" : ""; + final error = m.error == null ? "" : " ${m.error} |"; + final stack = m.stack == null ? "" : "\n${m.stack!}"; io.write('$created | $level | $logger | $message |$error$stack\n'); } } finally { @@ -114,16 +46,6 @@ class ImmichLogger { [XFile(filePath)], subject: "Immich logs $dateTime", sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, - ).then( - (value) => logFile.delete(), - ); - } - - /// Flush pending log messages to persistent storage - void flush() { - if (_timer != null) { - _timer!.cancel(); - _db.writeTxnSync(() => _db.loggerMessages.putAllSync(_msgBuffer)); - } + ).then((value) => logFile.delete()); } } diff --git a/mobile/lib/services/memory.service.dart b/mobile/lib/services/memory.service.dart index b95899df67..6ae8e1d0bb 100644 --- a/mobile/lib/services/memory.service.dart +++ b/mobile/lib/services/memory.service.dart @@ -6,7 +6,6 @@ import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; final memoryServiceProvider = StateProvider((ref) { return MemoryService( @@ -26,9 +25,8 @@ class MemoryService { Future?> getMemoryLane() async { try { final now = DateTime.now(); - final data = await _apiService.assetsApi.getMemoryLane( - now.day, - now.month, + final data = await _apiService.memoriesApi.searchMemories( + for_: DateTime.utc(now.year, now.month, now.day, 0, 0, 0), ); if (data == null) { @@ -36,9 +34,11 @@ class MemoryService { } List memories = []; - for (final MemoryLaneResponseDto(:yearsAgo, :assets) in data) { - final dbAssets = - await _assetRepository.getAllByRemoteId(assets.map((e) => e.id)); + + for (final memory in data) { + final dbAssets = await _assetRepository + .getAllByRemoteId(memory.assets.map((e) => e.id)); + final yearsAgo = now.year - memory.data.year; if (dbAssets.isNotEmpty) { final String title = yearsAgo <= 1 ? 'memories_year_ago'.tr() diff --git a/mobile/lib/services/oauth.service.dart b/mobile/lib/services/oauth.service.dart index 30e6448d7f..ddd97522f8 100644 --- a/mobile/lib/services/oauth.service.dart +++ b/mobile/lib/services/oauth.service.dart @@ -1,7 +1,7 @@ +import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; -import 'package:flutter_web_auth/flutter_web_auth.dart'; // Redirect URL = app.immich:///oauth-callback @@ -32,7 +32,7 @@ class OAuthService { } Future oAuthLogin(String oauthUrl) async { - String result = await FlutterWebAuth.authenticate( + String result = await FlutterWebAuth2.authenticate( url: oauthUrl, callbackUrlScheme: callbackUrlScheme, ); diff --git a/mobile/lib/services/partner.service.dart b/mobile/lib/services/partner.service.dart index 67d7f4e1d1..6bd429b51d 100644 --- a/mobile/lib/services/partner.service.dart +++ b/mobile/lib/services/partner.service.dart @@ -1,7 +1,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/partner.interface.dart'; import 'package:immich_mobile/interfaces/partner_api.interface.dart'; import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:immich_mobile/repositories/partner.repository.dart'; import 'package:immich_mobile/repositories/partner_api.repository.dart'; import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:logging/logging.dart'; @@ -10,19 +12,38 @@ final partnerServiceProvider = Provider( (ref) => PartnerService( ref.watch(partnerApiRepositoryProvider), ref.watch(userRepositoryProvider), + ref.watch(partnerRepositoryProvider), ), ); class PartnerService { final IPartnerApiRepository _partnerApiRepository; + final IPartnerRepository _partnerRepository; final IUserRepository _userRepository; final Logger _log = Logger("PartnerService"); PartnerService( this._partnerApiRepository, this._userRepository, + this._partnerRepository, ); + Future> getSharedWith() async { + return _partnerRepository.getSharedWith(); + } + + Future> getSharedBy() async { + return _partnerRepository.getSharedBy(); + } + + Stream> watchSharedWith() { + return _partnerRepository.watchSharedWith(); + } + + Stream> watchSharedBy() { + return _partnerRepository.watchSharedBy(); + } + Future removePartner(User partner) async { try { await _partnerApiRepository.delete(partner.id); diff --git a/mobile/lib/services/person.service.dart b/mobile/lib/services/person.service.dart index 5b325acdc5..a8289ac37d 100644 --- a/mobile/lib/services/person.service.dart +++ b/mobile/lib/services/person.service.dart @@ -1,3 +1,4 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset_api.interface.dart'; @@ -11,7 +12,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'person.service.g.dart'; @riverpod -PersonService personService(PersonServiceRef ref) => PersonService( +PersonService personService(Ref ref) => PersonService( ref.watch(personApiRepositoryProvider), ref.watch(assetApiRepositoryProvider), ref.read(assetRepositoryProvider), diff --git a/mobile/lib/services/person.service.g.dart b/mobile/lib/services/person.service.g.dart index 9a24069fbf..8c2d46b3bd 100644 --- a/mobile/lib/services/person.service.g.dart +++ b/mobile/lib/services/person.service.g.dart @@ -6,7 +6,7 @@ part of 'person.service.dart'; // RiverpodGenerator // ************************************************************************** -String _$personServiceHash() => r'32f28cb5a3de0553c17447e33a0efde7409a43ed'; +String _$personServiceHash() => r'10883bccc6c402205e6785cf9ee6cd7142cd0983'; /// See also [personService]. @ProviderFor(personService) @@ -20,6 +20,8 @@ final personServiceProvider = AutoDisposeProvider.internal( allTransitiveDependencies: null, ); +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element typedef PersonServiceRef = AutoDisposeProviderRef; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mobile/lib/services/search.service.dart b/mobile/lib/services/search.service.dart index ba46848cdd..4c6c80abf3 100644 --- a/mobile/lib/services/search.service.dart +++ b/mobile/lib/services/search.service.dart @@ -84,6 +84,10 @@ class SearchService { ? filter.filename : null, country: filter.location.country, + description: + filter.description != null && filter.description!.isNotEmpty + ? filter.description + : null, state: filter.location.state, city: filter.location.city, make: filter.camera.make, @@ -101,7 +105,7 @@ class SearchService { ); } - if (response == null) { + if (response == null || response.assets.items.isEmpty) { return null; } diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index 086ec097d1..b937dde320 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -2,28 +2,28 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/interfaces/exif.interface.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/interfaces/album.interface.dart'; import 'package:immich_mobile/interfaces/album_api.interface.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart'; -import 'package:immich_mobile/interfaces/exif_info.interface.dart'; import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:immich_mobile/providers/infrastructure/exif.provider.dart'; import 'package:immich_mobile/repositories/album.repository.dart'; import 'package:immich_mobile/repositories/album_api.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/etag.repository.dart'; -import 'package:immich_mobile/repositories/exif_info.repository.dart'; import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/utils/async_mutex.dart'; -import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/utils/datetime_comparison.dart'; import 'package:immich_mobile/utils/diff.dart'; import 'package:logging/logging.dart'; @@ -36,7 +36,7 @@ final syncServiceProvider = Provider( ref.watch(albumApiRepositoryProvider), ref.watch(albumRepositoryProvider), ref.watch(assetRepositoryProvider), - ref.watch(exifInfoRepositoryProvider), + ref.watch(exifRepositoryProvider), ref.watch(userRepositoryProvider), ref.watch(etagRepositoryProvider), ), @@ -286,7 +286,7 @@ class SyncService { } final idsToDelete = toRemove.map((e) => e.id).toList(); try { - await _assetRepository.deleteById(idsToDelete); + await _assetRepository.deleteByIds(idsToDelete); await upsertAssetsWithExif(toAdd + toUpdate); } catch (e) { _log.severe("Failed to sync remote assets to db", e); @@ -334,7 +334,7 @@ class SyncService { if (toDelete.isNotEmpty) { final List idsToRemove = sharedAssetsToRemove(toDelete, existing); if (idsToRemove.isNotEmpty) { - await _assetRepository.deleteById(idsToRemove); + await _assetRepository.deleteByIds(idsToRemove); } } else { assert(toDelete.isEmpty); @@ -531,7 +531,7 @@ class SyncService { ); if (toDelete.isNotEmpty || toUpdate.isNotEmpty) { await _assetRepository.transaction(() async { - await _assetRepository.deleteById(toDelete); + await _assetRepository.deleteByIds(toDelete); await _assetRepository.updateAll(toUpdate); }); _log.info( @@ -639,7 +639,7 @@ class SyncService { } /// fast path for common case: only new assets were added to device album - /// returns `true` if successfull, else `false` + /// returns `true` if successful, else `false` Future _syncDeviceAlbumFast(Album deviceAlbum, Album dbAlbum) async { if (!deviceAlbum.modifiedAt.isAfter(dbAlbum.modifiedAt)) { return false; @@ -756,7 +756,7 @@ class SyncService { await _assetRepository.transaction(() async { await _assetRepository.updateAll(assets); for (final Asset added in assets) { - added.exifInfo?.id = added.id; + added.exifInfo ??= added.exifInfo?.copyWith(assetId: added.id); } await _exifInfoRepository.updateAll(exifInfos); }); @@ -826,7 +826,7 @@ class SyncService { final (toDelete, toUpdate) = _handleAssetRemoval(assets, [], remote: false); await _assetRepository.transaction(() async { - await _assetRepository.deleteById(toDelete); + await _assetRepository.deleteByIds(toDelete); await _assetRepository.updateAll(toUpdate); await _albumRepository.deleteAllLocal(); }); diff --git a/mobile/lib/services/timeline.service.dart b/mobile/lib/services/timeline.service.dart new file mode 100644 index 0000000000..db85230662 --- /dev/null +++ b/mobile/lib/services/timeline.service.dart @@ -0,0 +1,108 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/timeline.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/repositories/timeline.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; + +final timelineServiceProvider = Provider((ref) { + return TimelineService( + ref.watch(timelineRepositoryProvider), + ref.watch(userRepositoryProvider), + ref.watch(appSettingsServiceProvider), + ); +}); + +class TimelineService { + final ITimelineRepository _timelineRepository; + final IUserRepository _userRepository; + final AppSettingsService _appSettingsService; + + const TimelineService( + this._timelineRepository, + this._userRepository, + this._appSettingsService, + ); + + Future> getTimelineUserIds() async { + final me = await _userRepository.me(); + return _timelineRepository.getTimelineUserIds(me.isarId); + } + + Stream> watchTimelineUserIds() async* { + final me = await _userRepository.me(); + yield* _timelineRepository.watchTimelineUsers(me.isarId); + } + + Stream watchHomeTimeline(int userId) { + return _timelineRepository.watchHomeTimeline(userId, _getGroupByOption()); + } + + Stream watchMultiUsersTimeline(List userIds) { + return _timelineRepository.watchMultiUsersTimeline( + userIds, + _getGroupByOption(), + ); + } + + Stream watchArchiveTimeline() async* { + final user = await _userRepository.me(); + + yield* _timelineRepository.watchArchiveTimeline(user.isarId); + } + + Stream watchFavoriteTimeline() async* { + final user = await _userRepository.me(); + + yield* _timelineRepository.watchFavoriteTimeline(user.isarId); + } + + Stream watchAlbumTimeline(Album album) async* { + yield* _timelineRepository.watchAlbumTimeline( + album, + _getGroupByOption(), + ); + } + + Stream watchTrashTimeline() async* { + final user = await _userRepository.me(); + + yield* _timelineRepository.watchTrashTimeline(user.isarId); + } + + Stream watchAllVideosTimeline() { + return _timelineRepository.watchAllVideosTimeline(); + } + + Future getTimelineFromAssets( + List assets, + GroupAssetsBy? groupBy, + ) { + GroupAssetsBy groupOption = GroupAssetsBy.none; + if (groupBy != null) { + groupOption = groupBy; + } else { + groupOption = _getGroupByOption(); + } + + return _timelineRepository.getTimelineFromAssets( + assets, + groupOption, + ); + } + + Stream watchAssetSelectionTimeline() async* { + final user = await _userRepository.me(); + + yield* _timelineRepository.watchAssetSelectionTimeline(user.isarId); + } + + GroupAssetsBy _getGroupByOption() { + return GroupAssetsBy + .values[_appSettingsService.getSetting(AppSettingsEnum.groupAssetsBy)]; + } +} diff --git a/mobile/lib/services/trash.service.dart b/mobile/lib/services/trash.service.dart index 9342b1f1e4..8d6cdd8bab 100644 --- a/mobile/lib/services/trash.service.dart +++ b/mobile/lib/services/trash.service.dart @@ -1,62 +1,86 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; + import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; + import 'package:immich_mobile/services/api.service.dart'; -import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; final trashServiceProvider = Provider((ref) { return TrashService( ref.watch(apiServiceProvider), + ref.watch(assetRepositoryProvider), + ref.watch(userRepositoryProvider), ); }); class TrashService { - final _log = Logger("TrashService"); - final ApiService _apiService; + final IAssetRepository _assetRepository; + final IUserRepository _userRepository; - TrashService(this._apiService); + TrashService(this._apiService, this._assetRepository, this._userRepository); - Future restoreAssets(Iterable assetList) async { - try { - List remoteIds = - assetList.where((a) => a.isRemote).map((e) => e.remoteId!).toList(); - await _apiService.trashApi.restoreAssets(BulkIdsDto(ids: remoteIds)); - return true; - } catch (error, stack) { - _log.severe("Cannot restore assets", error, stack); - return false; - } - } + Future restoreAssets(Iterable assetList) async { + final remoteAssets = assetList.where((a) => a.isRemote); + await _apiService.trashApi.restoreAssets( + BulkIdsDto(ids: remoteAssets.map((e) => e.remoteId!).toList()), + ); - Future restoreAsset(Asset asset) async { - try { - if (asset.isRemote) { - List remoteId = [asset.remoteId!]; + final updatedAssets = remoteAssets.map((asset) { + asset.isTrashed = false; + return asset; + }).toList(); - await _apiService.trashApi.restoreAssets(BulkIdsDto(ids: remoteId)); - } - return true; - } catch (error, stack) { - _log.severe("Cannot restore assets", error, stack); - return false; - } + await _assetRepository.updateAll(updatedAssets); } Future emptyTrash() async { - try { - await _apiService.trashApi.emptyTrash(); - } catch (error, stack) { - _log.severe("Cannot empty trash", error, stack); - } + final user = await _userRepository.me(); + + await _apiService.trashApi.emptyTrash(); + + final trashedAssets = await _assetRepository.getTrashAssets(user.isarId); + final ids = trashedAssets.map((e) => e.remoteId!).toList(); + + await _assetRepository.transaction(() async { + await _assetRepository.deleteAllByRemoteId( + ids, + state: AssetState.remote, + ); + + final merged = await _assetRepository.getAllByRemoteId( + ids, + state: AssetState.merged, + ); + if (merged.isEmpty) { + return; + } + + for (final Asset asset in merged) { + asset.remoteId = null; + asset.isTrashed = false; + } + + await _assetRepository.updateAll(merged); + }); } Future restoreTrash() async { - try { - await _apiService.trashApi.restoreTrash(); - } catch (error, stack) { - _log.severe("Cannot restore trash", error, stack); - } + final user = await _userRepository.me(); + + await _apiService.trashApi.restoreTrash(); + + final trashedAssets = await _assetRepository.getTrashAssets(user.isarId); + final updatedAssets = trashedAssets.map((asset) { + asset.isTrashed = false; + return asset; + }).toList(); + + await _assetRepository.updateAll(updatedAssets); } } diff --git a/mobile/lib/services/upload.service.dart b/mobile/lib/services/upload.service.dart index 1ffe01bb93..0734e57212 100644 --- a/mobile/lib/services/upload.service.dart +++ b/mobile/lib/services/upload.service.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:background_downloader/background_downloader.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'; import 'package:immich_mobile/interfaces/upload.interface.dart'; import 'package:immich_mobile/repositories/upload.repository.dart'; diff --git a/mobile/lib/services/user.service.dart b/mobile/lib/services/user.service.dart index 13adcc4e7a..921202ec59 100644 --- a/mobile/lib/services/user.service.dart +++ b/mobile/lib/services/user.service.dart @@ -1,14 +1,13 @@ import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/partner_api.interface.dart'; import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/interfaces/user_api.interface.dart'; import 'package:immich_mobile/repositories/partner_api.repository.dart'; import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/repositories/user_api.repository.dart'; -import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/utils/diff.dart'; import 'package:logging/logging.dart'; @@ -17,7 +16,6 @@ final userServiceProvider = Provider( ref.watch(partnerApiRepositoryProvider), ref.watch(userApiRepositoryProvider), ref.watch(userRepositoryProvider), - ref.watch(syncServiceProvider), ), ); @@ -25,14 +23,12 @@ class UserService { final IPartnerApiRepository _partnerApiRepository; final IUserApiRepository _userApiRepository; final IUserRepository _userRepository; - final SyncService _syncService; final Logger _log = Logger("UserService"); UserService( this._partnerApiRepository, this._userApiRepository, this._userRepository, - this._syncService, ); Future> getUsers({bool self = false}) { @@ -98,9 +94,7 @@ class UserService { return users; } - Future refreshUsers() async { - final users = await getUsersFromServer(); - if (users == null) return false; - return _syncService.syncUsersFromServer(users); + Future clearTable() { + return _userRepository.clearTable(); } } diff --git a/mobile/lib/theme/theme_data.dart b/mobile/lib/theme/theme_data.dart index de96e12c5d..33dc5dff54 100644 --- a/mobile/lib/theme/theme_data.dart +++ b/mobile/lib/theme/theme_data.dart @@ -24,9 +24,8 @@ ThemeData getThemeData({ hintColor: colorScheme.onSurfaceSecondary, focusColor: colorScheme.primary, scaffoldBackgroundColor: colorScheme.surface, - splashColor: colorScheme.primary.withOpacity(0.1), - highlightColor: colorScheme.primary.withOpacity(0.1), - dialogBackgroundColor: colorScheme.surfaceContainer, + splashColor: colorScheme.primary.withValues(alpha: 0.1), + highlightColor: colorScheme.primary.withValues(alpha: 0.1), bottomSheetTheme: BottomSheetThemeData( backgroundColor: colorScheme.surfaceContainer, ), @@ -163,6 +162,7 @@ ThemeData getThemeData({ ), ), ), + dialogTheme: DialogThemeData(backgroundColor: colorScheme.surfaceContainer), ); } diff --git a/mobile/lib/utils/bootstrap.dart b/mobile/lib/utils/bootstrap.dart new file mode 100644 index 0000000000..db019798a3 --- /dev/null +++ b/mobile/lib/utils/bootstrap.dart @@ -0,0 +1,56 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/domain/services/log.service.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/android_device_asset.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; +import 'package:immich_mobile/entities/etag.entity.dart'; +import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/log.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/log.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; +import 'package:isar/isar.dart'; +import 'package:path_provider/path_provider.dart'; + +abstract final class Bootstrap { + static Future initIsar() async { + if (Isar.getInstance() != null) { + return Isar.getInstance()!; + } + + final dir = await getApplicationDocumentsDirectory(); + return await Isar.open( + [ + StoreValueSchema, + ExifInfoSchema, + AssetSchema, + AlbumSchema, + UserSchema, + BackupAlbumSchema, + DuplicatedAssetSchema, + LoggerMessageSchema, + ETagSchema, + if (Platform.isAndroid) AndroidDeviceAssetSchema, + if (Platform.isIOS) IOSDeviceAssetSchema, + ], + directory: dir.path, + maxSizeMiB: 1024, + inspector: kDebugMode, + ); + } + + static Future initDomain(Isar db) async { + await StoreService.init(storeRepository: IsarStoreRepository(db)); + await LogService.init( + logRepository: IsarLogRepository(db), + storeRepository: IsarStoreRepository(db), + ); + } +} diff --git a/mobile/lib/utils/cache/custom_image_cache.dart b/mobile/lib/utils/cache/custom_image_cache.dart index a2a7839172..dcb8dacb0d 100644 --- a/mobile/lib/utils/cache/custom_image_cache.dart +++ b/mobile/lib/utils/cache/custom_image_cache.dart @@ -5,7 +5,7 @@ import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart' import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart'; /// [ImageCache] that uses two caches for small and large images -/// so that a single large image does not evict all small iamges +/// so that a single large image does not evict all small images final class CustomImageCache implements ImageCache { final _small = ImageCache(); final _large = ImageCache()..maximumSize = 5; // Maximum 5 images diff --git a/mobile/lib/utils/db.dart b/mobile/lib/utils/db.dart deleted file mode 100644 index 4d405468fa..0000000000 --- a/mobile/lib/utils/db.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:immich_mobile/entities/album.entity.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/etag.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:isar/isar.dart'; - -Future clearAssetsAndAlbums(Isar db) async { - await Store.delete(StoreKey.assetETag); - await db.writeTxn(() async { - await db.assets.clear(); - await db.exifInfos.clear(); - await db.albums.clear(); - await db.eTags.clear(); - await db.users.clear(); - }); -} diff --git a/mobile/lib/utils/http_ssl_cert_override.dart b/mobile/lib/utils/http_ssl_cert_override.dart index 9ce7334be2..ce0384b998 100644 --- a/mobile/lib/utils/http_ssl_cert_override.dart +++ b/mobile/lib/utils/http_ssl_cert_override.dart @@ -1,6 +1,8 @@ import 'dart:io'; -import 'package:immich_mobile/services/app_settings.service.dart'; + +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:logging/logging.dart'; class HttpSSLCertOverride extends HttpOverrides { diff --git a/mobile/lib/utils/image_url_builder.dart b/mobile/lib/utils/image_url_builder.dart index 9fc7b13eed..d063b3aa91 100644 --- a/mobile/lib/utils/image_url_builder.dart +++ b/mobile/lib/utils/image_url_builder.dart @@ -1,4 +1,5 @@ import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 681f8a22ce..990fc082d5 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -1,7 +1,12 @@ import 'dart:async'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/utils/db.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'; import 'package:isar/isar.dart'; const int targetVersion = 8; @@ -14,6 +19,13 @@ Future migrateDatabaseIfNeeded(Isar db) async { } Future _migrateTo(Isar db, int version) async { - await clearAssetsAndAlbums(db); + await Store.delete(StoreKey.assetETag); + await db.writeTxn(() async { + await db.assets.clear(); + await db.exifInfos.clear(); + await db.albums.clear(); + await db.eTags.clear(); + await db.users.clear(); + }); await Store.put(StoreKey.version, version); } diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index 255ad01247..708aec603f 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -10,6 +10,7 @@ dynamic upgradeDto(dynamic value, String targetType) { addDefault(value, 'ratings', RatingsResponse().toJson()); addDefault(value, 'people', PeopleResponse().toJson()); addDefault(value, 'tags', TagsResponse().toJson()); + addDefault(value, 'sharedLinks', SharedLinksResponse().toJson()); } break; case 'ServerConfigDto': diff --git a/mobile/lib/utils/renderlist_generator.dart b/mobile/lib/utils/renderlist_generator.dart deleted file mode 100644 index a601ef068d..0000000000 --- a/mobile/lib/utils/renderlist_generator.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; -import 'package:immich_mobile/providers/app_settings.provider.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:isar/isar.dart'; - -Stream renderListGenerator( - QueryBuilder query, - StreamProviderRef ref, -) { - final settings = ref.watch(appSettingsServiceProvider); - final groupBy = - GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)]; - return renderListGeneratorWithGroupBy(query, groupBy); -} - -Stream renderListGeneratorWithGroupBy( - QueryBuilder query, - GroupAssetsBy groupBy, -) async* { - yield await RenderList.fromQuery(query, groupBy); - await for (final _ in query.watchLazy()) { - yield await RenderList.fromQuery(query, groupBy); - } -} diff --git a/mobile/lib/utils/selection_handlers.dart b/mobile/lib/utils/selection_handlers.dart index 56160a2efc..4a9fcc8c99 100644 --- a/mobile/lib/utils/selection_handlers.dart +++ b/mobile/lib/utils/selection_handlers.dart @@ -2,9 +2,9 @@ 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/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/asset_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/services/asset.service.dart'; import 'package:immich_mobile/services/share.service.dart'; diff --git a/mobile/lib/utils/storage_indicator.dart b/mobile/lib/utils/storage_indicator.dart index 4764c45385..a7dad063ca 100644 --- a/mobile/lib/utils/storage_indicator.dart +++ b/mobile/lib/utils/storage_indicator.dart @@ -2,13 +2,8 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; /// Returns the suitable [IconData] to represent an [Asset]s storage location -IconData storageIcon(Asset asset) { - switch (asset.storage) { - case AssetState.local: - return Icons.cloud_off_outlined; - case AssetState.remote: - return Icons.cloud_outlined; - case AssetState.merged: - return Icons.cloud_done_outlined; - } -} +IconData storageIcon(Asset asset) => switch (asset.storage) { + AssetState.local => Icons.cloud_off_outlined, + AssetState.remote => Icons.cloud_outlined, + AssetState.merged => Icons.cloud_done_outlined, + }; diff --git a/mobile/lib/utils/url_helper.dart b/mobile/lib/utils/url_helper.dart index d351cb5816..6b355e362f 100644 --- a/mobile/lib/utils/url_helper.dart +++ b/mobile/lib/utils/url_helper.dart @@ -1,3 +1,4 @@ +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; String sanitizeUrl(String url) { diff --git a/mobile/lib/widgets/activities/activity_tile.dart b/mobile/lib/widgets/activities/activity_tile.dart index 1c89a39b46..2dd16b73cb 100644 --- a/mobile/lib/widgets/activities/activity_tile.dart +++ b/mobile/lib/widgets/activities/activity_tile.dart @@ -63,7 +63,7 @@ class _ActivityTitle extends StatelessWidget { Widget build(BuildContext context) { final textColor = context.isDarkTheme ? Colors.white : Colors.black; final textStyle = context.textTheme.bodyMedium - ?.copyWith(color: textColor.withOpacity(0.6)); + ?.copyWith(color: textColor.withValues(alpha: 0.6)); return Row( mainAxisAlignment: diff --git a/mobile/lib/widgets/album/album_thumbnail_card.dart b/mobile/lib/widgets/album/album_thumbnail_card.dart index b728f2b541..ac62ecee03 100644 --- a/mobile/lib/widgets/album/album_thumbnail_card.dart +++ b/mobile/lib/widgets/album/album_thumbnail_card.dart @@ -1,8 +1,9 @@ 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/domain/models/store.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; diff --git a/mobile/lib/widgets/album/album_viewer_appbar.dart b/mobile/lib/widgets/album/album_viewer_appbar.dart index b058f29e7d..451987316b 100644 --- a/mobile/lib/widgets/album/album_viewer_appbar.dart +++ b/mobile/lib/widgets/album/album_viewer_appbar.dart @@ -58,12 +58,7 @@ class AlbumViewerAppbar extends HookConsumerWidget final bool success = await ref.watch(albumProvider.notifier).deleteAlbum(album); - if (album.shared) { - context.navigateTo(const TabControllerRoute(children: [AlbumsRoute()])); - } else { - context - .navigateTo(const TabControllerRoute(children: [LibraryRoute()])); - } + context.navigateTo(const TabControllerRoute(children: [AlbumsRoute()])); if (!success) { ImmichToast.show( diff --git a/mobile/lib/widgets/album/album_viewer_editable_title.dart b/mobile/lib/widgets/album/album_viewer_editable_title.dart index 7547dff932..72fdfe070d 100644 --- a/mobile/lib/widgets/album/album_viewer_editable_title.dart +++ b/mobile/lib/widgets/album/album_viewer_editable_title.dart @@ -16,7 +16,14 @@ class AlbumViewerEditableTitle extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final titleTextEditController = useTextEditingController(text: albumName); + final albumViewerState = ref.watch(albumViewerProvider); + + final titleTextEditController = useTextEditingController( + text: albumViewerState.isEditAlbum && + albumViewerState.editTitleText.isNotEmpty + ? albumViewerState.editTitleText + : albumName, + ); void onFocusModeChange() { if (!titleFocusNode.hasFocus && titleTextEditController.text.isEmpty) { diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid.dart index 17872852e5..5c1aa6ef69 100644 --- a/mobile/lib/widgets/asset_grid/immich_asset_grid.dart +++ b/mobile/lib/widgets/asset_grid/immich_asset_grid.dart @@ -6,7 +6,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/asyncvalue_extensions.dart'; -import 'package:immich_mobile/providers/asset_viewer/render_list.provider.dart'; +import 'package:immich_mobile/providers/timeline.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid_view.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; @@ -125,7 +125,7 @@ class ImmichAssetGrid extends HookConsumerWidget { if (renderList != null) return buildAssetGridView(renderList!); - final renderListFuture = ref.watch(renderListProvider(assets!)); + final renderListFuture = ref.watch(assetsTimelineProvider(assets!)); return renderListFuture.widgetWhen( onData: (renderList) => buildAssetGridView(renderList), ); diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart index c38e61a473..d870c5abe2 100644 --- a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart @@ -332,7 +332,7 @@ class ImmichAssetGridViewState extends ConsumerState { ); } - if (index != -1 && index < widget.renderList.elements.length) { + if (index < widget.renderList.elements.length) { // Not sure why the index is shifted, but it works. :3 _scrollToIndex(index + 1); } else { diff --git a/mobile/lib/widgets/asset_grid/multiselect_grid.dart b/mobile/lib/widgets/asset_grid/multiselect_grid.dart index 6bcd6e5784..03d04b682f 100644 --- a/mobile/lib/widgets/asset_grid/multiselect_grid.dart +++ b/mobile/lib/widgets/asset_grid/multiselect_grid.dart @@ -200,24 +200,26 @@ class MultiselectGrid extends HookConsumerWidget { } } - void onDeleteLocal(bool onlyBackedUp) async { + void onDeleteLocal(bool isMergedAsset) async { processing.value = true; try { - // Select only the local assets from the selection - final localIds = selection.value.where((a) => a.isLocal).toList(); + final localAssets = selection.value.where((a) => a.isLocal).toList(); + + final toDelete = isMergedAsset + ? localAssets.where((e) => e.storage == AssetState.merged) + : localAssets; + + if (toDelete.isEmpty) { + return; + } - // Delete only the backed-up assets if 'onlyBackedUp' is true final isDeleted = await ref .read(assetProvider.notifier) - .deleteLocalOnlyAssets(localIds, onlyBackedUp: onlyBackedUp); + .deleteLocalAssets(toDelete.toList()); if (isDeleted) { - // Show a toast with the correct number of deleted assets - final deletedCount = localIds - .where( - (e) => !onlyBackedUp || e.isRemote, - ) // Only count backed-up assets - .length; + final deletedCount = + localAssets.where((e) => !isMergedAsset || e.isRemote).length; ImmichToast.show( context: context, @@ -226,7 +228,6 @@ class MultiselectGrid extends HookConsumerWidget { gravity: ToastGravity.BOTTOM, ); - // Reset the selection selectionEnabledHook.value = false; } } finally { @@ -234,7 +235,7 @@ class MultiselectGrid extends HookConsumerWidget { } } - void onDeleteRemote([bool force = false]) async { + void onDeleteRemote([bool shouldDeletePermanently = false]) async { processing.value = true; try { final toDelete = ownedRemoteSelection( @@ -242,13 +243,15 @@ class MultiselectGrid extends HookConsumerWidget { ownerErrorMessage: 'home_page_delete_err_partner'.tr(), ).toList(); - final isDeleted = await ref - .read(assetProvider.notifier) - .deleteRemoteOnlyAssets(toDelete, force: force); + final isDeleted = + await ref.read(assetProvider.notifier).deleteRemoteAssets( + toDelete, + shouldDeletePermanently: shouldDeletePermanently, + ); if (isDeleted) { ImmichToast.show( context: context, - msg: force + msg: shouldDeletePermanently ? 'assets_deleted_permanently_from_server' .tr(args: ["${toDelete.length}"]) : 'assets_trashed_from_server'.tr(args: ["${toDelete.length}"]), diff --git a/mobile/lib/widgets/asset_grid/thumbnail_image.dart b/mobile/lib/widgets/asset_grid/thumbnail_image.dart index 35013bb595..d25b7a3e90 100644 --- a/mobile/lib/widgets/asset_grid/thumbnail_image.dart +++ b/mobile/lib/widgets/asset_grid/thumbnail_image.dart @@ -202,8 +202,15 @@ class ThumbnailImage extends ConsumerWidget { bottom: 5, child: Icon( storageIcon(asset), - color: Colors.white.withOpacity(.8), + color: Colors.white.withValues(alpha: .8), size: 16, + shadows: [ + Shadow( + blurRadius: 5.0, + color: Colors.black.withValues(alpha: 0.6), + offset: const Offset(0.0, 0.0), + ), + ], ), ), if (asset.isFavorite) diff --git a/mobile/lib/widgets/asset_viewer/description_input.dart b/mobile/lib/widgets/asset_viewer/description_input.dart index 3fdd40130a..2d9d71ac9e 100644 --- a/mobile/lib/widgets/asset_viewer/description_input.dart +++ b/mobile/lib/widgets/asset_viewer/description_input.dart @@ -2,9 +2,9 @@ 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/entities/exif_info.entity.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; diff --git a/mobile/lib/widgets/asset_viewer/detail_panel/asset_details.dart b/mobile/lib/widgets/asset_viewer/detail_panel/asset_details.dart index a78a309512..59b52344e7 100644 --- a/mobile/lib/widgets/asset_viewer/detail_panel/asset_details.dart +++ b/mobile/lib/widgets/asset_viewer/detail_panel/asset_details.dart @@ -1,12 +1,12 @@ 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/exif.model.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; -import 'package:immich_mobile/widgets/asset_viewer/detail_panel/file_info.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/widgets/asset_viewer/detail_panel/camera_info.dart'; +import 'package:immich_mobile/widgets/asset_viewer/detail_panel/file_info.dart'; class AssetDetails extends ConsumerWidget { final Asset asset; diff --git a/mobile/lib/widgets/asset_viewer/detail_panel/asset_location.dart b/mobile/lib/widgets/asset_viewer/detail_panel/asset_location.dart index 364b568d0a..40e09cb5c8 100644 --- a/mobile/lib/widgets/asset_viewer/detail_panel/asset_location.dart +++ b/mobile/lib/widgets/asset_viewer/detail_panel/asset_location.dart @@ -1,12 +1,12 @@ 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/exif.model.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/utils/selection_handlers.dart'; import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; class AssetLocation extends HookConsumerWidget { final Asset asset; diff --git a/mobile/lib/widgets/asset_viewer/detail_panel/camera_info.dart b/mobile/lib/widgets/asset_viewer/detail_panel/camera_info.dart index e6720e0255..aec18c6a16 100644 --- a/mobile/lib/widgets/asset_viewer/detail_panel/camera_info.dart +++ b/mobile/lib/widgets/asset_viewer/detail_panel/camera_info.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; class CameraInfo extends StatelessWidget { diff --git a/mobile/lib/widgets/asset_viewer/detail_panel/exif_map.dart b/mobile/lib/widgets/asset_viewer/detail_panel/exif_map.dart index 7878404273..f3f72dfd87 100644 --- a/mobile/lib/widgets/asset_viewer/detail_panel/exif_map.dart +++ b/mobile/lib/widgets/asset_viewer/detail_panel/exif_map.dart @@ -1,8 +1,8 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/widgets/map/map_thumbnail.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:url_launcher/url_launcher.dart'; diff --git a/mobile/lib/widgets/asset_viewer/detail_panel/people_info.dart b/mobile/lib/widgets/asset_viewer/detail_panel/people_info.dart index f917f03b37..2e868682f8 100644 --- a/mobile/lib/widgets/asset_viewer/detail_panel/people_info.dart +++ b/mobile/lib/widgets/asset_viewer/detail_panel/people_info.dart @@ -44,7 +44,19 @@ class PeopleInfo extends ConsumerWidget { } final curatedPeople = people - ?.map((p) => SearchCuratedContent(id: p.id, label: p.name)) + ?.map( + (p) => SearchCuratedContent( + id: p.id, + label: p.name, + subtitle: p.birthDate != null + ? "exif_bottom_sheet_person_age".tr( + args: [ + _calculateAge(p.birthDate!).toString(), + ], + ) + : null, + ), + ) .toList() ?? []; @@ -99,4 +111,17 @@ class PeopleInfo extends ConsumerWidget { ), ); } + + int _calculateAge(DateTime birthDate) { + DateTime today = DateTime.now(); + int age = today.year - birthDate.year; + + // Check if the birthday has occurred this year + if (today.month < birthDate.month || + (today.month == birthDate.month && today.day < birthDate.day)) { + age--; + } + + return age; + } } diff --git a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart index f7e2158ea9..80be3e46cc 100644 --- a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart @@ -5,6 +5,8 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/scroll_to_date_notifier.provider.dart'; +import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/widgets/album/add_to_album_bottom_sheet.dart'; import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; @@ -49,7 +51,8 @@ class GalleryAppBar extends ConsumerWidget { } handleRestore(Asset asset) async { - final result = await ref.read(trashProvider.notifier).restoreAsset(asset); + final result = + await ref.read(trashProvider.notifier).restoreAssets([asset]); if (result && context.mounted) { ImmichToast.show( @@ -94,18 +97,29 @@ class GalleryAppBar extends ConsumerWidget { ref.read(downloadStateProvider.notifier).downloadAsset(asset, context); } + handleLocateAsset() async { + // Go back to the gallery + await context.maybePop(); + await context + .navigateTo(const TabControllerRoute(children: [PhotosRoute()])); + ref.read(tabProvider.notifier).update((state) => state = TabEnum.home); + // Scroll to the asset's date + scrollToDateNotifierProvider.scrollToDate(asset.fileCreatedAt); + } + return IgnorePointer( ignoring: !showControls, child: AnimatedOpacity( duration: const Duration(milliseconds: 100), opacity: showControls ? 1.0 : 0.0, child: Container( - color: Colors.black.withOpacity(0.4), + color: Colors.black.withValues(alpha: 0.4), child: TopControlAppBar( isOwner: isOwner, isPartner: isPartner, asset: asset, onMoreInfoPressed: showInfo, + onLocatePressed: handleLocateAsset, onFavorite: toggleFavorite, onRestorePressed: () => handleRestore(asset), onUploadPressed: asset.isLocal ? () => handleUpload(asset) : null, diff --git a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart index 2bdbb72ec0..ef8f2e687b 100644 --- a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/providers/activity_statistics.provider.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; +import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart'; class TopControlAppBar extends HookConsumerWidget { @@ -13,6 +14,7 @@ class TopControlAppBar extends HookConsumerWidget { required this.asset, required this.onMoreInfoPressed, required this.onDownloadPressed, + required this.onLocatePressed, required this.onAddToAlbumPressed, required this.onRestorePressed, required this.onFavorite, @@ -26,6 +28,7 @@ class TopControlAppBar extends HookConsumerWidget { final Function onMoreInfoPressed; final VoidCallback? onUploadPressed; final VoidCallback? onDownloadPressed; + final VoidCallback onLocatePressed; final VoidCallback onAddToAlbumPressed; final VoidCallback onRestorePressed; final VoidCallback onActivitiesPressed; @@ -54,6 +57,18 @@ class TopControlAppBar extends HookConsumerWidget { ); } + Widget buildLocateButton() { + return IconButton( + onPressed: () { + onLocatePressed(); + }, + icon: Icon( + Icons.image_search, + color: Colors.grey[200], + ), + ); + } + Widget buildMoreInfoButton() { return IconButton( onPressed: () { @@ -159,6 +174,8 @@ class TopControlAppBar extends HookConsumerWidget { shape: const Border(), actions: [ if (asset.isRemote && isOwner) buildFavoriteButton(a), + if (isOwner && ref.read(tabProvider.notifier).state != TabEnum.home) + buildLocateButton(), if (asset.livePhotoVideoId != null) const MotionPhotoButton(), if (asset.isLocal && !asset.isRemote) buildUploadButton(), if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(), 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 218e17cbe1..94d413859e 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 @@ -134,7 +134,7 @@ class ImmichAppBarDialog extends HookConsumerWidget { ref.read(manualUploadProvider.notifier).cancelBackup(); ref.read(backupProvider.notifier).cancelBackup(); - ref.read(assetProvider.notifier).clearAllAsset(); + ref.read(assetProvider.notifier).clearAllAssets(); ref.read(websocketProvider.notifier).disconnect(); context.replaceRoute(const LoginRoute()); }, 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 f0006d1ada..d51e122954 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,14 +1,15 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.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/theme_extensions.dart'; -import 'package:immich_mobile/providers/upload_profile_image.provider.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/providers/upload_profile_image.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; +import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; class AppBarProfileInfoBox extends HookConsumerWidget { const AppBarProfileInfoBox({ @@ -67,7 +68,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget { ); if (user != null) { user.profileImagePath = profileImagePath; - Store.put(StoreKey.currentUser, user); + await Store.put(StoreKey.currentUser, user); ref.read(currentUserProvider.notifier).refresh(); } } 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 8cab0bd72f..9c6f4a62fa 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 @@ -170,7 +170,7 @@ class AppBarServerInfo extends HookConsumerWidget { child: Tooltip( verticalOffset: 0, decoration: BoxDecoration( - color: context.primaryColor.withOpacity(0.9), + color: context.primaryColor.withValues(alpha: 0.9), borderRadius: BorderRadius.circular(10), ), textStyle: TextStyle( diff --git a/mobile/lib/widgets/common/date_time_picker.dart b/mobile/lib/widgets/common/date_time_picker.dart index d90ee40e47..4e4e24e18c 100644 --- a/mobile/lib/widgets/common/date_time_picker.dart +++ b/mobile/lib/widgets/common/date_time_picker.dart @@ -4,6 +4,7 @@ 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/widgets/common/dropdown_search_menu.dart'; import 'package:timezone/timezone.dart' as tz; import 'package:timezone/timezone.dart'; @@ -24,7 +25,7 @@ Future showDateTimePicker({ } String _getFormattedOffset(int offsetInMilli, tz.Location location) { - return "${location.name} (UTC${Duration(milliseconds: offsetInMilli).formatAsOffset()})"; + return "${location.name} (${Duration(milliseconds: offsetInMilli).formatAsOffset()})"; } class _DateTimePicker extends HookWidget { @@ -73,7 +74,6 @@ class _DateTimePicker extends HookWidget { // returns a list of location along with it's offset in duration List<_TimeZoneOffset> getAllTimeZones() { return tz.timeZoneDatabase.locations.values - .where((l) => !l.currentTimeZone.abbreviation.contains("0")) .map(_TimeZoneOffset.fromLocation) .sorted() .toList(); @@ -133,83 +133,78 @@ class _DateTimePicker extends HookWidget { context.pop(dtWithOffset); } - return LayoutBuilder( - builder: (context, constraint) => AlertDialog( - contentPadding: - const EdgeInsets.symmetric(vertical: 32, horizontal: 18), - actions: [ - TextButton( - onPressed: () => context.pop(), - child: Text( - "action_common_cancel", - style: context.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - color: context.colorScheme.error, + return AlertDialog( + contentPadding: const EdgeInsets.symmetric(vertical: 32, horizontal: 18), + actions: [ + TextButton( + onPressed: () => context.pop(), + child: Text( + "action_common_cancel", + style: context.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: context.colorScheme.error, + ), + ).tr(), + ), + TextButton( + onPressed: popWithDateTime, + child: Text( + "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( + "edit_date_time_dialog_date_time", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ).tr(), + const SizedBox(height: 32), + ListTile( + tileColor: context.colorScheme.surfaceContainerHighest, + shape: ShapeBorder.lerp( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), ), + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + 1, + ), + trailing: Icon( + Icons.edit_outlined, + size: 18, + color: context.primaryColor, + ), + title: Text( + DateFormat("dd-MM-yyyy hh:mm a").format(date.value), + style: context.textTheme.bodyMedium, ).tr(), + onTap: pickDate, ), - TextButton( - onPressed: popWithDateTime, - child: Text( - "action_common_update", - style: context.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - color: context.primaryColor, - ), - ).tr(), + const SizedBox(height: 24), + DropdownSearchMenu( + trailingIcon: Icon( + Icons.arrow_drop_down, + color: context.primaryColor, + ), + hintText: "edit_date_time_dialog_timezone".tr(), + label: const Text('edit_date_time_dialog_timezone').tr(), + textStyle: context.textTheme.bodyMedium, + onSelected: (value) => tzOffset.value = value, + initialSelection: tzOffset.value, + dropdownMenuEntries: menuEntries, ), ], - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - "edit_date_time_dialog_date_time", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ).tr(), - const SizedBox(height: 32), - ListTile( - tileColor: context.colorScheme.surfaceContainerHighest, - shape: ShapeBorder.lerp( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - 1, - ), - trailing: Icon( - Icons.edit_outlined, - size: 18, - color: context.primaryColor, - ), - title: Text( - DateFormat("dd-MM-yyyy hh:mm a").format(date.value), - style: context.textTheme.bodyMedium, - ).tr(), - onTap: pickDate, - ), - const SizedBox(height: 24), - DropdownMenu( - width: 275, - menuHeight: 300, - trailingIcon: Icon( - Icons.arrow_drop_down, - color: context.primaryColor, - ), - hintText: "edit_date_time_dialog_timezone".tr(), - label: const Text('edit_date_time_dialog_timezone').tr(), - textStyle: context.textTheme.bodyMedium, - onSelected: (value) => tzOffset.value = value!, - initialSelection: tzOffset.value, - dropdownMenuEntries: menuEntries, - ), - ], - ), ), ); } diff --git a/mobile/lib/widgets/common/dropdown_search_menu.dart b/mobile/lib/widgets/common/dropdown_search_menu.dart new file mode 100644 index 0000000000..5b73d649f8 --- /dev/null +++ b/mobile/lib/widgets/common/dropdown_search_menu.dart @@ -0,0 +1,169 @@ +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'; + +class DropdownSearchMenu extends HookWidget { + const DropdownSearchMenu({ + super.key, + required this.dropdownMenuEntries, + this.initialSelection, + this.onSelected, + this.trailingIcon, + this.hintText, + this.label, + this.textStyle, + this.menuConstraints, + }); + + final List> dropdownMenuEntries; + final T? initialSelection; + final ValueChanged? onSelected; + final Widget? trailingIcon; + final String? hintText; + final Widget? label; + final TextStyle? textStyle; + final BoxConstraints? menuConstraints; + + @override + Widget build(BuildContext context) { + final selectedItem = useState?>( + dropdownMenuEntries + .firstWhereOrNull((item) => item.value == initialSelection), + ); + final showTimeZoneDropdown = useState(false); + + final effectiveConstraints = menuConstraints ?? + const BoxConstraints( + minWidth: 280, + maxWidth: 280, + minHeight: 0, + maxHeight: 280, + ); + + final inputDecoration = InputDecoration( + contentPadding: const EdgeInsets.fromLTRB(12, 4, 12, 4), + border: const OutlineInputBorder(), + suffixIcon: trailingIcon, + label: label, + hintText: hintText, + ).applyDefaults(context.themeData.inputDecorationTheme); + + if (!showTimeZoneDropdown.value) { + return ConstrainedBox( + constraints: effectiveConstraints, + child: GestureDetector( + onTap: () => showTimeZoneDropdown.value = true, + child: InputDecorator( + decoration: inputDecoration, + child: selectedItem.value != null + ? Text( + selectedItem.value!.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyle, + ) + : null, + ), + ), + ); + } + + return ConstrainedBox( + constraints: effectiveConstraints, + child: Autocomplete>( + displayStringForOption: (option) => option.label, + optionsBuilder: (textEditingValue) { + return dropdownMenuEntries.where( + (item) => item.label + .toLowerCase() + .trim() + .contains(textEditingValue.text.toLowerCase().trim()), + ); + }, + onSelected: (option) { + selectedItem.value = option; + showTimeZoneDropdown.value = false; + onSelected?.call(option.value); + }, + fieldViewBuilder: (context, textEditingController, focusNode, _) { + return TextField( + autofocus: true, + focusNode: focusNode, + controller: textEditingController, + decoration: inputDecoration.copyWith( + hintText: "edit_date_time_dialog_search_timezone".tr(), + ), + maxLines: 1, + style: context.textTheme.bodyMedium, + expands: false, + onTapOutside: (event) { + showTimeZoneDropdown.value = false; + focusNode.unfocus(); + }, + onSubmitted: (_) { + showTimeZoneDropdown.value = false; + }, + ); + }, + optionsViewBuilder: (context, onSelected, options) { + // This widget is a copy of the default implementation. + // We have only changed the `constraints` parameter. + return Align( + alignment: Alignment.topLeft, + child: ConstrainedBox( + constraints: effectiveConstraints, + child: Material( + elevation: 4.0, + child: ListView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + itemCount: options.length, + itemBuilder: (BuildContext context, int index) { + final option = options.elementAt(index); + return InkWell( + onTap: () => onSelected(option), + child: Builder( + builder: (BuildContext context) { + final bool highlight = + AutocompleteHighlightedOption.of(context) == + index; + if (highlight) { + SchedulerBinding.instance.addPostFrameCallback( + (Duration timeStamp) { + Scrollable.ensureVisible( + context, + alignment: 0.5, + ); + }, + debugLabel: 'AutocompleteOptions.ensureVisible', + ); + } + return Container( + color: highlight + ? Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.12) + : null, + padding: const EdgeInsets.all(16.0), + child: Text( + option.label, + style: textStyle, + ), + ); + }, + ), + ); + }, + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/mobile/lib/widgets/common/immich_app_bar.dart b/mobile/lib/widgets/common/immich_app_bar.dart index 1831a2d168..7c1e0c1c4e 100644 --- a/mobile/lib/widgets/common/immich_app_bar.dart +++ b/mobile/lib/widgets/common/immich_app_bar.dart @@ -3,17 +3,17 @@ import 'package:easy_localization/easy_localization.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/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/providers/immich_logo_provider.dart'; -import 'package:immich_mobile/widgets/common/app_bar_dialog/app_bar_dialog.dart'; -import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; - -import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/models/server_info/server_info.model.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; +import 'package:immich_mobile/providers/immich_logo_provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/common/app_bar_dialog/app_bar_dialog.dart'; +import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { @override @@ -124,7 +124,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { decoration: BoxDecoration( color: badgeBackground, border: Border.all( - color: context.colorScheme.outline.withOpacity(.3), + color: context.colorScheme.outline.withValues(alpha: .3), ), borderRadius: BorderRadius.circular(widgetSize / 2), ), diff --git a/mobile/lib/widgets/common/immich_image.dart b/mobile/lib/widgets/common/immich_image.dart index ab0f2584b5..243ef55412 100644 --- a/mobile/lib/widgets/common/immich_image.dart +++ b/mobile/lib/widgets/common/immich_image.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/image/immich_local_image_provider.dart'; import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:octo_image/octo_image.dart'; class ImmichImage extends StatelessWidget { diff --git a/mobile/lib/widgets/common/immich_toast.dart b/mobile/lib/widgets/common/immich_toast.dart index d33f6c4caf..7f3207032b 100644 --- a/mobile/lib/widgets/common/immich_toast.dart +++ b/mobile/lib/widgets/common/immich_toast.dart @@ -15,36 +15,26 @@ class ImmichToast { final fToast = FToast(); fToast.init(context); - Color getColor(ToastType type, BuildContext context) { - switch (type) { - case ToastType.info: - return context.primaryColor; - case ToastType.success: - return const Color.fromARGB(255, 78, 140, 124); - case ToastType.error: - return const Color.fromARGB(255, 220, 48, 85); - } - } + Color getColor(ToastType type, BuildContext context) => switch (type) { + ToastType.info => context.primaryColor, + ToastType.success => const Color.fromARGB(255, 78, 140, 124), + ToastType.error => const Color.fromARGB(255, 220, 48, 85), + }; - Icon getIcon(ToastType type) { - switch (type) { - case ToastType.info: - return Icon( - Icons.info_outline_rounded, - color: context.primaryColor, - ); - case ToastType.success: - return const Icon( - Icons.check_circle_rounded, - color: Color.fromARGB(255, 78, 140, 124), - ); - case ToastType.error: - return const Icon( - Icons.error_outline_rounded, - color: Color.fromARGB(255, 240, 162, 156), - ); - } - } + Icon getIcon(ToastType type) => switch (type) { + ToastType.info => Icon( + Icons.info_outline_rounded, + color: context.primaryColor, + ), + ToastType.success => const Icon( + Icons.check_circle_rounded, + color: Color.fromARGB(255, 78, 140, 124), + ), + ToastType.error => const Icon( + Icons.error_outline_rounded, + color: Color.fromARGB(255, 240, 162, 156), + ), + }; fToast.showToast( child: Container( @@ -53,7 +43,7 @@ class ImmichToast { borderRadius: BorderRadius.circular(5.0), color: context.colorScheme.surfaceContainer, border: Border.all( - color: context.colorScheme.outline.withOpacity(.5), + color: context.colorScheme.outline.withValues(alpha: .5), width: 1, ), ), diff --git a/mobile/lib/widgets/common/scaffold_error_body.dart b/mobile/lib/widgets/common/scaffold_error_body.dart index bca2934c23..5011d229e7 100644 --- a/mobile/lib/widgets/common/scaffold_error_body.dart +++ b/mobile/lib/widgets/common/scaffold_error_body.dart @@ -27,7 +27,8 @@ class ScaffoldErrorBody extends StatelessWidget { child: Icon( Icons.error_outline, size: 100, - color: context.themeData.iconTheme.color?.withOpacity(0.5), + color: + context.themeData.iconTheme.color?.withValues(alpha: 0.5), ), ), ), diff --git a/mobile/lib/widgets/common/user_avatar.dart b/mobile/lib/widgets/common/user_avatar.dart index 9a577d94b3..62491210c9 100644 --- a/mobile/lib/widgets/common/user_avatar.dart +++ b/mobile/lib/widgets/common/user_avatar.dart @@ -1,8 +1,9 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/services/api.service.dart'; Widget userAvatar(BuildContext context, User u, {double? radius}) { diff --git a/mobile/lib/widgets/common/user_circle_avatar.dart b/mobile/lib/widgets/common/user_circle_avatar.dart index 50da009676..2b7eadf04b 100644 --- a/mobile/lib/widgets/common/user_circle_avatar.dart +++ b/mobile/lib/widgets/common/user_circle_avatar.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:cached_network_image/cached_network_image.dart'; 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/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; @@ -28,8 +29,7 @@ class UserCircleAvatar extends ConsumerWidget { final profileImageUrl = '${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${Random().nextInt(1024)}'; - final textIcon = Text( - user.name[0].toUpperCase(), + final textIcon = DefaultTextStyle( style: TextStyle( fontWeight: FontWeight.bold, fontSize: 12, @@ -37,6 +37,7 @@ class UserCircleAvatar extends ConsumerWidget { ? Colors.black : Colors.white, ), + child: Text(user.name[0].toUpperCase()), ); return CircleAvatar( backgroundColor: user.avatarColor.toColor(), diff --git a/mobile/lib/widgets/forms/change_password_form.dart b/mobile/lib/widgets/forms/change_password_form.dart index fbb8fd927b..7c375844b8 100644 --- a/mobile/lib/widgets/forms/change_password_form.dart +++ b/mobile/lib/widgets/forms/change_password_form.dart @@ -85,7 +85,7 @@ class ChangePasswordForm extends HookConsumerWidget { ref.read(backupProvider.notifier).cancelBackup(); await ref .read(assetProvider.notifier) - .clearAllAsset(); + .clearAllAssets(); ref.read(websocketProvider.notifier).disconnect(); AutoRouter.of(context).back(); diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 30b6a74bb1..dc63841193 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -10,7 +10,6 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/oauth.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; @@ -150,7 +149,7 @@ class LoginForm extends HookConsumerWidget { useEffect( () { - final serverUrl = Store.tryGet(StoreKey.serverUrl); + final serverUrl = getServerUrl(); if (serverUrl != null) { serverEndpointController.text = serverUrl; } @@ -168,7 +167,7 @@ class LoginForm extends HookConsumerWidget { populateTestLoginInfo1() { emailController.text = 'testuser@email.com'; passwordController.text = 'password'; - serverEndpointController.text = 'http://10.1.15.216:3000/api'; + serverEndpointController.text = 'http://10.1.15.216:2283/api'; } login() async { diff --git a/mobile/lib/widgets/map/map_asset_grid.dart b/mobile/lib/widgets/map/map_asset_grid.dart index 7205feefa4..18003cf293 100644 --- a/mobile/lib/widgets/map/map_asset_grid.dart +++ b/mobile/lib/widgets/map/map_asset_grid.dart @@ -6,7 +6,7 @@ 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/collection_extensions.dart'; -import 'package:immich_mobile/providers/asset_viewer/render_list.provider.dart'; +import 'package:immich_mobile/providers/timeline.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/models/map/map_event.model.dart'; @@ -126,7 +126,9 @@ class MapAssetGrid extends HookConsumerWidget { // Place it just below the drag handle heightFactor: 0.80, child: assetsInBounds.value.isNotEmpty - ? ref.watch(renderListProvider(assetsInBounds.value)).when( + ? ref + .watch(assetsTimelineProvider(assetsInBounds.value)) + .when( data: (renderList) { // Cache render list here to use it back during visibleItemsListener cachedRenderList.value = renderList; diff --git a/mobile/lib/widgets/map/map_bottom_sheet.dart b/mobile/lib/widgets/map/map_bottom_sheet.dart index d52a426469..0249ca70dc 100644 --- a/mobile/lib/widgets/map/map_bottom_sheet.dart +++ b/mobile/lib/widgets/map/map_bottom_sheet.dart @@ -59,9 +59,10 @@ class MapBottomSheet extends HookConsumerWidget { child: DraggableScrollableSheet( controller: sheetController, minChildSize: sheetMinExtent, - maxChildSize: 0.5, + maxChildSize: 0.8, initialChildSize: sheetMinExtent, snap: true, + snapSizes: [sheetMinExtent, 0.5, 0.8], shouldCloseOnMinExtent: false, builder: (ctx, scrollController) => MapAssetGrid( controller: scrollController, @@ -78,18 +79,23 @@ class MapBottomSheet extends HookConsumerWidget { ), ValueListenableBuilder( valueListenable: bottomSheetOffset, - builder: (ctx, value, child) => Positioned( - right: 0, - bottom: context.height * (value + 0.02), - child: child!, - ), - child: ElevatedButton( - onPressed: onZoomToLocation, - style: ElevatedButton.styleFrom( - shape: const CircleBorder(), - ), - child: const Icon(Icons.my_location), - ), + builder: (context, value, child) { + return Positioned( + right: 0, + bottom: context.height * (value + 0.02), + child: AnimatedOpacity( + opacity: value < 0.8 ? 1 : 0, + duration: const Duration(milliseconds: 150), + child: ElevatedButton( + onPressed: onZoomToLocation, + style: ElevatedButton.styleFrom( + shape: const CircleBorder(), + ), + child: const Icon(Icons.my_location), + ), + ), + ); + }, ), ], ); diff --git a/mobile/lib/widgets/map/map_thumbnail.dart b/mobile/lib/widgets/map/map_thumbnail.dart index b856f09787..b225a2edcb 100644 --- a/mobile/lib/widgets/map/map_thumbnail.dart +++ b/mobile/lib/widgets/map/map_thumbnail.dart @@ -41,10 +41,10 @@ class MapThumbnail extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final offsettedCentre = LatLng(centre.latitude + 0.002, centre.longitude); - final controller = useRef(null); + final controller = useRef(null); final position = useValueNotifier?>(null); - Future onMapCreated(MaplibreMapController mapController) async { + Future onMapCreated(MapLibreMapController mapController) async { controller.value = mapController; if (assetMarkerRemoteId != null) { // The iOS impl returns wrong toScreenLocation without the delay @@ -73,7 +73,7 @@ class MapThumbnail extends HookConsumerWidget { alignment: Alignment.center, children: [ style.widgetWhen( - onData: (style) => MaplibreMap( + onData: (style) => MapLibreMap( initialCameraPosition: CameraPosition(target: offsettedCentre, zoom: zoom), styleString: style, diff --git a/mobile/lib/widgets/memories/memory_bottom_info.dart b/mobile/lib/widgets/memories/memory_bottom_info.dart index 84f4cb6c72..6adf1d46b0 100644 --- a/mobile/lib/widgets/memories/memory_bottom_info.dart +++ b/mobile/lib/widgets/memories/memory_bottom_info.dart @@ -48,7 +48,7 @@ class MemoryBottomInfo extends StatelessWidget { .scrollToDate(memory.assets[0].fileCreatedAt); }, shape: const CircleBorder(), - color: Colors.white.withOpacity(0.2), + color: Colors.white.withValues(alpha: 0.2), elevation: 0, child: const Icon( Icons.open_in_new, diff --git a/mobile/lib/widgets/memories/memory_card.dart b/mobile/lib/widgets/memories/memory_card.dart index 4954d0bfcc..abe3586194 100644 --- a/mobile/lib/widgets/memories/memory_card.dart +++ b/mobile/lib/widgets/memories/memory_card.dart @@ -75,11 +75,12 @@ class MemoryCard extends StatelessWidget { key: ValueKey(asset.id), asset: asset, showControls: false, + playbackDelayFactor: 2, image: ImmichImage( asset, width: context.width, height: context.height, - fit: fit, + fit: BoxFit.contain, ), ), ), @@ -125,7 +126,7 @@ class _BlurredBackdrop extends HookWidget { ), ), child: Container( - color: Colors.black.withOpacity(0.2), + color: Colors.black.withValues(alpha: 0.2), ), ); } else { @@ -146,7 +147,7 @@ class _BlurredBackdrop extends HookWidget { ), ), child: Container( - color: Colors.black.withOpacity(0.2), + color: Colors.black.withValues(alpha: 0.2), ), ), ); diff --git a/mobile/lib/widgets/memories/memory_lane.dart b/mobile/lib/widgets/memories/memory_lane.dart index 41e9cc628e..3f97bd1ea4 100644 --- a/mobile/lib/widgets/memories/memory_lane.dart +++ b/mobile/lib/widgets/memories/memory_lane.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; @@ -29,10 +31,17 @@ class MemoryLane extends HookConsumerWidget { elevation: 2, backgroundColor: Colors.black, overlayColor: WidgetStateProperty.all( - Colors.white.withOpacity(0.1), + Colors.white.withValues(alpha: 0.1), ), onTap: (memoryIndex) { ref.read(hapticFeedbackProvider.notifier).heavyImpact(); + if (memories[memoryIndex].assets.isNotEmpty) { + final asset = memories[memoryIndex].assets[0]; + ref.read(currentAssetProvider.notifier).set(asset); + if (asset.isVideo || asset.isMotionPhoto) { + ref.read(videoPlaybackValueProvider.notifier).reset(); + } + } context.pushRoute( MemoryRoute( memories: memories, @@ -75,7 +84,7 @@ class MemoryCard extends ConsumerWidget { children: [ ColorFiltered( colorFilter: ColorFilter.mode( - Colors.black.withOpacity(0.2), + Colors.black.withValues(alpha: 0.2), BlendMode.darken, ), child: Hero( diff --git a/mobile/lib/widgets/photo_view/photo_view.dart b/mobile/lib/widgets/photo_view/photo_view.dart index 7f72750afe..f72d1e298f 100644 --- a/mobile/lib/widgets/photo_view/photo_view.dart +++ b/mobile/lib/widgets/photo_view/photo_view.dart @@ -590,21 +590,15 @@ class _PhotoViewState extends State } /// The default [ScaleStateCycle] -PhotoViewScaleState defaultScaleStateCycle(PhotoViewScaleState actual) { - switch (actual) { - case PhotoViewScaleState.initial: - return PhotoViewScaleState.covering; - case PhotoViewScaleState.covering: - return PhotoViewScaleState.originalSize; - case PhotoViewScaleState.originalSize: - return PhotoViewScaleState.initial; - case PhotoViewScaleState.zoomedIn: - case PhotoViewScaleState.zoomedOut: - return PhotoViewScaleState.initial; - default: - return PhotoViewScaleState.initial; - } -} +PhotoViewScaleState defaultScaleStateCycle(PhotoViewScaleState actual) => + switch (actual) { + PhotoViewScaleState.initial => PhotoViewScaleState.covering, + PhotoViewScaleState.covering => PhotoViewScaleState.originalSize, + PhotoViewScaleState.originalSize => PhotoViewScaleState.initial, + PhotoViewScaleState.zoomedIn || + PhotoViewScaleState.zoomedOut => + PhotoViewScaleState.initial, + }; /// A type definition for a [Function] that receives the actual [PhotoViewScaleState] and returns the next one /// It is used internally to walk in the "doubletap gesture cycle". 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 d91e9f51dd..facd701725 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 @@ -9,29 +9,24 @@ double getScaleForScaleState( PhotoViewScaleState scaleState, ScaleBoundaries scaleBoundaries, ) { - switch (scaleState) { - case PhotoViewScaleState.initial: - case PhotoViewScaleState.zoomedIn: - case PhotoViewScaleState.zoomedOut: - return _clampSize(scaleBoundaries.initialScale, scaleBoundaries); - case PhotoViewScaleState.covering: - return _clampSize( + return switch (scaleState) { + PhotoViewScaleState.initial || + PhotoViewScaleState.zoomedIn || + PhotoViewScaleState.zoomedOut => + _clampSize(scaleBoundaries.initialScale, scaleBoundaries), + PhotoViewScaleState.covering => _clampSize( _scaleForCovering( scaleBoundaries.outerSize, scaleBoundaries.childSize, ), scaleBoundaries, - ); - case PhotoViewScaleState.originalSize: - return _clampSize(1.0, scaleBoundaries); - // Will never be reached - default: - return 0; - } + ), + PhotoViewScaleState.originalSize => _clampSize(1.0, scaleBoundaries), + }; } /// Internal class to wraps custom scale boundaries (min, max and initial) -/// Also, stores values regarding the two sizes: the container and teh child. +/// Also, stores values regarding the two sizes: the container and the child. class ScaleBoundaries { const ScaleBoundaries( this._minScale, diff --git a/mobile/lib/widgets/search/curated_people_row.dart b/mobile/lib/widgets/search/curated_people_row.dart index 2ec57d6a1a..eece328392 100644 --- a/mobile/lib/widgets/search/curated_people_row.dart +++ b/mobile/lib/widgets/search/curated_people_row.dart @@ -86,12 +86,22 @@ class CuratedPeopleRow extends StatelessWidget { ).tr(), ); } - return Text( - person.label, - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - style: context.textTheme.labelLarge, - maxLines: 2, + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + person.label, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelLarge, + maxLines: 2, + ), + if (person.subtitle != null) + Text( + person.subtitle!, + textAlign: TextAlign.center, + ), + ], ); } } diff --git a/mobile/lib/widgets/search/curated_places_row.dart b/mobile/lib/widgets/search/curated_places_row.dart index c4cfdedc27..502b09bc4b 100644 --- a/mobile/lib/widgets/search/curated_places_row.dart +++ b/mobile/lib/widgets/search/curated_places_row.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/models/search/search_curated_content.model.dart'; import 'package:immich_mobile/widgets/search/search_map_thumbnail.dart'; import 'package:immich_mobile/widgets/search/thumbnail_with_info.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; class CuratedPlacesRow extends StatelessWidget { const CuratedPlacesRow({ diff --git a/mobile/lib/widgets/search/explore_grid.dart b/mobile/lib/widgets/search/explore_grid.dart index cd937a6a42..1841f7f051 100644 --- a/mobile/lib/widgets/search/explore_grid.dart +++ b/mobile/lib/widgets/search/explore_grid.dart @@ -1,12 +1,13 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; -import 'package:immich_mobile/models/search/search_curated_content.model.dart'; -import 'package:immich_mobile/models/search/search_filter.model.dart'; -import 'package:immich_mobile/widgets/search/thumbnail_with_info.dart'; -import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/models/search/search_curated_content.model.dart'; +import 'package:immich_mobile/models/search/search_filter.model.dart'; +import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; +import 'package:immich_mobile/widgets/search/thumbnail_with_info.dart'; class ExploreGrid extends StatelessWidget { final List curatedContent; diff --git a/mobile/lib/widgets/search/search_filter/people_picker.dart b/mobile/lib/widgets/search/search_filter/people_picker.dart index dfc435c807..2c45e2097c 100644 --- a/mobile/lib/widgets/search/search_filter/people_picker.dart +++ b/mobile/lib/widgets/search/search_filter/people_picker.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:easy_localization/easy_localization.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/theme_extensions.dart'; import 'package:immich_mobile/interfaces/person_api.interface.dart'; +import 'package:immich_mobile/pages/common/large_leading_tile.dart'; import 'package:immich_mobile/providers/search/people.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; @@ -16,63 +19,138 @@ class PeoplePicker extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - var imageSize = 45.0; + final formFocus = useFocusNode(); + final imageSize = 60.0; + final searchQuery = useState(''); final people = ref.watch(getAllPeopleProvider); final headers = ApiService.getRequestHeaders(); final selectedPeople = useState>(filter ?? {}); - return people.widgetWhen( - onData: (people) { - return ListView.builder( - shrinkWrap: true, - itemCount: people.length, + return Column( + children: [ + Padding( padding: const EdgeInsets.all(8), - itemBuilder: (context, index) { - final person = people[index]; - return Card( - elevation: 0, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(15)), + child: TextField( + focusNode: formFocus, + onChanged: (value) => searchQuery.value = value, + onTapOutside: (_) => formFocus.unfocus(), + decoration: InputDecoration( + contentPadding: const EdgeInsets.only(left: 24), + filled: true, + fillColor: context.primaryColor.withValues(alpha: 0.1), + hintStyle: context.textTheme.bodyLarge?.copyWith( + color: context.themeData.colorScheme.onSurfaceSecondary, ), - child: ListTile( - title: Text( - person.name, - style: context.textTheme.bodyLarge, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.surfaceContainerHighest, ), - leading: SizedBox( - height: imageSize, - child: Material( - shape: const CircleBorder(side: BorderSide.none), - elevation: 3, - child: CircleAvatar( - maxRadius: imageSize / 2, - backgroundImage: NetworkImage( - getFaceThumbnailUrl(person.id), - headers: headers, - ), - ), - ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.surfaceContainerHighest, ), - onTap: () { - if (selectedPeople.value.contains(person)) { - selectedPeople.value.remove(person); - } else { - selectedPeople.value.add(person); - } + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.surfaceContainerHighest, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.primary.withAlpha(150), + ), + ), + prefixIcon: Icon( + Icons.search_rounded, + color: context.colorScheme.primary, + ), + hintText: 'search_filter_people_hint'.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) { + return ListView.builder( + shrinkWrap: true, + itemCount: people + .where( + (person) => person.name + .toLowerCase() + .contains(searchQuery.value.toLowerCase()), + ) + .length, + padding: const EdgeInsets.all(8), + itemBuilder: (context, index) { + final person = people + .where( + (person) => person.name + .toLowerCase() + .contains(searchQuery.value.toLowerCase()), + ) + .toList()[index]; + final isSelected = selectedPeople.value.contains(person); - selectedPeople.value = {...selectedPeople.value}; - onSelect(selectedPeople.value); + return Padding( + 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( + maxRadius: imageSize / 2, + backgroundImage: NetworkImage( + getFaceThumbnailUrl(person.id), + headers: headers, + ), + ), + ), + ), + 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), + ), + ); }, - selected: selectedPeople.value.contains(person), - selectedTileColor: context.primaryColor.withOpacity(0.2), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(15)), - ), - ), - ); - }, - ); - }, + ); + }, + ), + ), + ], ); } } diff --git a/mobile/lib/widgets/search/search_filter/search_filter_chip.dart b/mobile/lib/widgets/search/search_filter/search_filter_chip.dart index 2a445c8ad7..c1e628adeb 100644 --- a/mobile/lib/widgets/search/search_filter/search_filter_chip.dart +++ b/mobile/lib/widgets/search/search_filter/search_filter_chip.dart @@ -22,7 +22,7 @@ class SearchFilterChip extends StatelessWidget { onTap: onTap, child: Card( elevation: 0, - color: context.primaryColor.withOpacity(.5), + color: context.primaryColor.withValues(alpha: .5), shape: StadiumBorder( side: BorderSide(color: context.colorScheme.secondaryContainer), ), diff --git a/mobile/lib/widgets/search/thumbnail_with_info_container.dart b/mobile/lib/widgets/search/thumbnail_with_info_container.dart index d2084bdcc8..1f5f3c2d16 100644 --- a/mobile/lib/widgets/search/thumbnail_with_info_container.dart +++ b/mobile/lib/widgets/search/thumbnail_with_info_container.dart @@ -44,8 +44,8 @@ class ThumbnailWithInfoContainer extends StatelessWidget { colors: [ Colors.transparent, label == '' - ? Colors.black.withOpacity(0.1) - : Colors.black.withOpacity(0.5), + ? Colors.black.withValues(alpha: 0.1) + : Colors.black.withValues(alpha: 0.5), ], stops: const [0.0, 1.0], ), diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index ec1ab79cf7..4e399e8aec 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -1,18 +1,19 @@ import 'dart:io'; + 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/providers/user.provider.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/utils/http_ssl_cert_override.dart'; import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custome_proxy_headers_settings.dart'; import 'package:immich_mobile/widgets/settings/local_storage_settings.dart'; import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; -import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/services/immich_logger.service.dart'; -import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; import 'package:immich_mobile/widgets/settings/ssl_client_cert_settings.dart'; import 'package:logging/logging.dart'; @@ -33,7 +34,8 @@ class AdvancedSettings extends HookConsumerWidget { useValueChanged( levelId.value, - (_, __) => ImmichLogger().level = Level.LEVELS[levelId.value], + (_, __) => + LogService.I.setlogLevel(Level.LEVELS[levelId.value].toLogLevel()), ); final advancedSettings = [ 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 13c109fa0e..633d84c9c8 100644 --- a/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart +++ b/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart @@ -2,11 +2,12 @@ import 'dart:convert'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_hooks/flutter_hooks.dart' hide Store; 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/models/auth/auxilary_endpoint.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart' as db_store; import 'package:immich_mobile/widgets/settings/networking_settings/endpoint_input.dart'; class ExternalNetworkPreference extends HookConsumerWidget { @@ -30,8 +31,8 @@ class ExternalNetworkPreference extends HookConsumerWidget { final jsonString = jsonEncode(endpointList); - db_store.Store.put( - db_store.StoreKey.externalEndpointList, + Store.put( + StoreKey.externalEndpointList, jsonString, ); } @@ -71,7 +72,7 @@ class ExternalNetworkPreference extends HookConsumerWidget { builder: (BuildContext context, Widget? child) { return Material( color: context.colorScheme.surfaceContainerHighest, - shadowColor: context.colorScheme.primary.withOpacity(0.2), + shadowColor: context.colorScheme.primary.withValues(alpha: 0.2), child: child, ); }, @@ -81,8 +82,7 @@ class ExternalNetworkPreference extends HookConsumerWidget { useEffect( () { - final jsonString = - db_store.Store.tryGet(db_store.StoreKey.externalEndpointList); + final jsonString = Store.tryGet(StoreKey.externalEndpointList); if (jsonString == null) { return null; @@ -116,7 +116,7 @@ class ExternalNetworkPreference extends HookConsumerWidget { child: Icon( Icons.dns_rounded, size: 120, - color: context.primaryColor.withOpacity(0.05), + color: context.primaryColor.withValues(alpha: 0.05), ), ), ListView( 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 0258cc3847..a50d216a9d 100644 --- a/mobile/lib/widgets/settings/networking_settings/local_network_preference.dart +++ b/mobile/lib/widgets/settings/networking_settings/local_network_preference.dart @@ -102,7 +102,7 @@ class LocalNetworkPreference extends HookConsumerWidget { final localEndpoint = await _showEditDialog( context, "server_endpoint".tr(), - "http://local-ip:2283/api", + "http://local-ip:2283", localEndpointText.value, ); @@ -161,7 +161,7 @@ class LocalNetworkPreference extends HookConsumerWidget { child: Icon( Icons.home_outlined, size: 120, - color: context.primaryColor.withOpacity(0.05), + color: context.primaryColor.withValues(alpha: 0.05), ), ), ListView( @@ -212,7 +212,7 @@ class LocalNetworkPreference extends HookConsumerWidget { leading: const Icon(Icons.lan_rounded), title: Text("server_endpoint".tr()), subtitle: localEndpointText.value.isEmpty - ? const Text("http://local-ip:2283/api") + ? const Text("http://local-ip:2283") : Text( localEndpointText.value, style: context.textTheme.labelLarge?.copyWith( diff --git a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart index 59d05fd4cf..587a0ce6d3 100644 --- a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart +++ b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart @@ -1,25 +1,23 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.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/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/providers/network.provider.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/utils/url_helper.dart'; import 'package:immich_mobile/widgets/settings/networking_settings/external_network_preference.dart'; import 'package:immich_mobile/widgets/settings/networking_settings/local_network_preference.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; -import 'package:immich_mobile/entities/store.entity.dart' as db_store; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; - class NetworkingSettings extends HookConsumerWidget { const NetworkingSettings({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final currentEndpoint = - db_store.Store.get(db_store.StoreKey.serverEndpoint); + final currentEndpoint = getServerUrl(); final featureEnabled = useAppSettingsState(AppSettingsEnum.autoEndpointSwitching); @@ -103,7 +101,7 @@ class NetworkingSettings extends HookConsumerWidget { padding: const EdgeInsets.only(top: 8, left: 16, bottom: 8), child: NetworkPreferenceTitle( title: "current_server_address".tr().toUpperCase(), - icon: currentEndpoint.startsWith('https') + icon: (currentEndpoint?.startsWith('https') ?? false) ? Icons.https_outlined : Icons.http_outlined, ), @@ -120,10 +118,16 @@ class NetworkingSettings extends HookConsumerWidget { ), ), child: ListTile( - leading: - const Icon(Icons.check_circle_rounded, color: Colors.green), + leading: currentEndpoint != null + ? const Icon( + Icons.check_circle_rounded, + color: Colors.green, + ) + : const Icon( + Icons.circle_outlined, + ), title: Text( - currentEndpoint, + currentEndpoint ?? "--", style: TextStyle( fontSize: 16, fontFamily: 'Inconsolata', @@ -220,23 +224,20 @@ class NetworkStatusIcon extends StatelessWidget { ); } - Widget _buildIcon(BuildContext context) { - switch (status) { - case AuxCheckStatus.loading: - return Padding( - padding: const EdgeInsets.only(left: 4.0), - child: SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - color: context.primaryColor, - strokeWidth: 2, - key: const ValueKey('loading'), + Widget _buildIcon(BuildContext context) => switch (status) { + AuxCheckStatus.loading => Padding( + padding: const EdgeInsets.only(left: 4.0), + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + color: context.primaryColor, + strokeWidth: 2, + key: const ValueKey('loading'), + ), ), ), - ); - case AuxCheckStatus.valid: - return enabled + AuxCheckStatus.valid => enabled ? const Icon( Icons.check_circle_rounded, color: Colors.green, @@ -246,9 +247,8 @@ class NetworkStatusIcon extends StatelessWidget { Icons.check_circle_rounded, color: context.colorScheme.onSurface.withAlpha(100), key: const ValueKey('success'), - ); - case AuxCheckStatus.error: - return enabled + ), + AuxCheckStatus.error => enabled ? const Icon( Icons.error_rounded, color: Colors.red, @@ -258,9 +258,7 @@ class NetworkStatusIcon extends StatelessWidget { Icons.error_rounded, color: Colors.grey, key: ValueKey('error'), - ); - default: - return const Icon(Icons.circle_outlined, key: ValueKey('unknown')); - } - } + ), + _ => const Icon(Icons.circle_outlined, key: ValueKey('unknown')), + }; } 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 119407ccad..af34ab9e16 100644 --- a/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart +++ b/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart @@ -98,7 +98,7 @@ class PrimaryColorSetting extends HookConsumerWidget { child: Container( decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(100)), - color: Colors.grey[900]?.withOpacity(.4), + color: Colors.grey[900]?.withValues(alpha: .4), ), child: const Padding( padding: EdgeInsets.all(3), diff --git a/mobile/lib/widgets/shared_link/shared_link_item.dart b/mobile/lib/widgets/shared_link/shared_link_item.dart index a9ed359280..09724a37d3 100644 --- a/mobile/lib/widgets/shared_link/shared_link_item.dart +++ b/mobile/lib/widgets/shared_link/shared_link_item.dart @@ -240,7 +240,7 @@ class SharedLinkItem extends ConsumerWidget { child: Tooltip( verticalOffset: 0, decoration: BoxDecoration( - color: colorScheme.primary.withOpacity(0.9), + color: colorScheme.primary.withValues(alpha: 0.9), borderRadius: BorderRadius.circular(10), ), textStyle: TextStyle( @@ -268,7 +268,7 @@ class SharedLinkItem extends ConsumerWidget { child: Tooltip( verticalOffset: 0, decoration: BoxDecoration( - color: colorScheme.primary.withOpacity(0.9), + color: colorScheme.primary.withValues(alpha: 0.9), borderRadius: BorderRadius.circular(10), ), textStyle: TextStyle( diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index f239026c0a..85409a7934 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.125.1 +- API version: 1.129.0 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen @@ -93,23 +93,22 @@ Class | Method | HTTP request | Description *AlbumsApi* | [**removeUserFromAlbum**](doc//AlbumsApi.md#removeuserfromalbum) | **DELETE** /albums/{id}/user/{userId} | *AlbumsApi* | [**updateAlbumInfo**](doc//AlbumsApi.md#updatealbuminfo) | **PATCH** /albums/{id} | *AlbumsApi* | [**updateAlbumUser**](doc//AlbumsApi.md#updatealbumuser) | **PUT** /albums/{id}/user/{userId} | -*AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /assets/bulk-upload-check | Checks if assets exist by checksums -*AssetsApi* | [**checkExistingAssets**](doc//AssetsApi.md#checkexistingassets) | **POST** /assets/exist | Checks if multiple assets exist on the server and returns all existing - used by background backup +*AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /assets/bulk-upload-check | checkBulkUpload +*AssetsApi* | [**checkExistingAssets**](doc//AssetsApi.md#checkexistingassets) | **POST** /assets/exist | checkExistingAssets *AssetsApi* | [**deleteAssets**](doc//AssetsApi.md#deleteassets) | **DELETE** /assets | *AssetsApi* | [**downloadAsset**](doc//AssetsApi.md#downloadasset) | **GET** /assets/{id}/original | -*AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | Get all asset of a device that are in the database, ID only. +*AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | getAllUserAssetsByDeviceId *AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} | *AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics | *AssetsApi* | [**getMemoryLane**](doc//AssetsApi.md#getmemorylane) | **GET** /assets/memory-lane | *AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random | *AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback | -*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | Replace the asset with new file, without changing its id +*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | replaceAsset *AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs | *AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} | *AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /assets | *AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets | *AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail | -*AuditApi* | [**getAuditDeletes**](doc//AuditApi.md#getauditdeletes) | **GET** /audit/deletes | *AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | *AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login | *AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout | @@ -119,6 +118,8 @@ Class | Method | HTTP request | Description *DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | *DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates | +*FacesApi* | [**createFace**](doc//FacesApi.md#createface) | **POST** /faces | +*FacesApi* | [**deleteFace**](doc//FacesApi.md#deleteface) | **DELETE** /faces/{id} | *FacesApi* | [**getFaces**](doc//FacesApi.md#getfaces) | **GET** /faces | *FacesApi* | [**reassignFacesById**](doc//FacesApi.md#reassignfacesbyid) | **PUT** /faces/{id} | *FileReportsApi* | [**fixAuditFiles**](doc//FileReportsApi.md#fixauditfiles) | **POST** /reports/fix | @@ -202,8 +203,12 @@ Class | Method | HTTP request | Description *StacksApi* | [**getStack**](doc//StacksApi.md#getstack) | **GET** /stacks/{id} | *StacksApi* | [**searchStacks**](doc//StacksApi.md#searchstacks) | **GET** /stacks | *StacksApi* | [**updateStack**](doc//StacksApi.md#updatestack) | **PUT** /stacks/{id} | +*SyncApi* | [**deleteSyncAck**](doc//SyncApi.md#deletesyncack) | **DELETE** /sync/ack | *SyncApi* | [**getDeltaSync**](doc//SyncApi.md#getdeltasync) | **POST** /sync/delta-sync | *SyncApi* | [**getFullSyncForUser**](doc//SyncApi.md#getfullsyncforuser) | **POST** /sync/full-sync | +*SyncApi* | [**getSyncAck**](doc//SyncApi.md#getsyncack) | **GET** /sync/ack | +*SyncApi* | [**getSyncStream**](doc//SyncApi.md#getsyncstream) | **POST** /sync/stream | +*SyncApi* | [**sendSyncAck**](doc//SyncApi.md#sendsyncack) | **POST** /sync/ack | *SystemConfigApi* | [**getConfig**](doc//SystemConfigApi.md#getconfig) | **GET** /system-config | *SystemConfigApi* | [**getConfigDefaults**](doc//SystemConfigApi.md#getconfigdefaults) | **GET** /system-config/defaults | *SystemConfigApi* | [**getStorageTemplateOptions**](doc//SystemConfigApi.md#getstoragetemplateoptions) | **GET** /system-config/storage-template-options | @@ -275,6 +280,8 @@ Class | Method | HTTP request | Description - [AssetBulkUploadCheckResult](doc//AssetBulkUploadCheckResult.md) - [AssetDeltaSyncDto](doc//AssetDeltaSyncDto.md) - [AssetDeltaSyncResponseDto](doc//AssetDeltaSyncResponseDto.md) + - [AssetFaceCreateDto](doc//AssetFaceCreateDto.md) + - [AssetFaceDeleteDto](doc//AssetFaceDeleteDto.md) - [AssetFaceResponseDto](doc//AssetFaceResponseDto.md) - [AssetFaceUpdateDto](doc//AssetFaceUpdateDto.md) - [AssetFaceUpdateItem](doc//AssetFaceUpdateItem.md) @@ -293,7 +300,6 @@ Class | Method | HTTP request | Description - [AssetStatsResponseDto](doc//AssetStatsResponseDto.md) - [AssetTypeEnum](doc//AssetTypeEnum.md) - [AudioCodec](doc//AudioCodec.md) - - [AuditDeletesResponseDto](doc//AuditDeletesResponseDto.md) - [AvatarResponse](doc//AvatarResponse.md) - [AvatarUpdate](doc//AvatarUpdate.md) - [BulkIdResponseDto](doc//BulkIdResponseDto.md) @@ -317,7 +323,6 @@ Class | Method | HTTP request | Description - [DuplicateResponseDto](doc//DuplicateResponseDto.md) - [EmailNotificationsResponse](doc//EmailNotificationsResponse.md) - [EmailNotificationsUpdate](doc//EmailNotificationsUpdate.md) - - [EntityType](doc//EntityType.md) - [ExifResponseDto](doc//ExifResponseDto.md) - [FaceDto](doc//FaceDto.md) - [FacialRecognitionConfig](doc//FacialRecognitionConfig.md) @@ -408,12 +413,27 @@ Class | Method | HTTP request | Description - [SharedLinkEditDto](doc//SharedLinkEditDto.md) - [SharedLinkResponseDto](doc//SharedLinkResponseDto.md) - [SharedLinkType](doc//SharedLinkType.md) + - [SharedLinksResponse](doc//SharedLinksResponse.md) + - [SharedLinksUpdate](doc//SharedLinksUpdate.md) - [SignUpDto](doc//SignUpDto.md) - [SmartSearchDto](doc//SmartSearchDto.md) - [SourceType](doc//SourceType.md) - [StackCreateDto](doc//StackCreateDto.md) - [StackResponseDto](doc//StackResponseDto.md) - [StackUpdateDto](doc//StackUpdateDto.md) + - [SyncAckDeleteDto](doc//SyncAckDeleteDto.md) + - [SyncAckDto](doc//SyncAckDto.md) + - [SyncAckSetDto](doc//SyncAckSetDto.md) + - [SyncAssetDeleteV1](doc//SyncAssetDeleteV1.md) + - [SyncAssetExifV1](doc//SyncAssetExifV1.md) + - [SyncAssetV1](doc//SyncAssetV1.md) + - [SyncEntityType](doc//SyncEntityType.md) + - [SyncPartnerDeleteV1](doc//SyncPartnerDeleteV1.md) + - [SyncPartnerV1](doc//SyncPartnerV1.md) + - [SyncRequestType](doc//SyncRequestType.md) + - [SyncStreamDto](doc//SyncStreamDto.md) + - [SyncUserDeleteV1](doc//SyncUserDeleteV1.md) + - [SyncUserV1](doc//SyncUserV1.md) - [SystemConfigBackupsDto](doc//SystemConfigBackupsDto.md) - [SystemConfigDto](doc//SystemConfigDto.md) - [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 73eb02d89e..eddd63a732 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -34,7 +34,6 @@ part 'api/api_keys_api.dart'; part 'api/activities_api.dart'; part 'api/albums_api.dart'; part 'api/assets_api.dart'; -part 'api/audit_api.dart'; part 'api/authentication_api.dart'; part 'api/deprecated_api.dart'; part 'api/download_api.dart'; @@ -88,6 +87,8 @@ part 'model/asset_bulk_upload_check_response_dto.dart'; part 'model/asset_bulk_upload_check_result.dart'; part 'model/asset_delta_sync_dto.dart'; part 'model/asset_delta_sync_response_dto.dart'; +part 'model/asset_face_create_dto.dart'; +part 'model/asset_face_delete_dto.dart'; part 'model/asset_face_response_dto.dart'; part 'model/asset_face_update_dto.dart'; part 'model/asset_face_update_item.dart'; @@ -106,7 +107,6 @@ part 'model/asset_stack_response_dto.dart'; part 'model/asset_stats_response_dto.dart'; part 'model/asset_type_enum.dart'; part 'model/audio_codec.dart'; -part 'model/audit_deletes_response_dto.dart'; part 'model/avatar_response.dart'; part 'model/avatar_update.dart'; part 'model/bulk_id_response_dto.dart'; @@ -130,7 +130,6 @@ part 'model/duplicate_detection_config.dart'; part 'model/duplicate_response_dto.dart'; part 'model/email_notifications_response.dart'; part 'model/email_notifications_update.dart'; -part 'model/entity_type.dart'; part 'model/exif_response_dto.dart'; part 'model/face_dto.dart'; part 'model/facial_recognition_config.dart'; @@ -221,12 +220,27 @@ part 'model/shared_link_create_dto.dart'; part 'model/shared_link_edit_dto.dart'; part 'model/shared_link_response_dto.dart'; part 'model/shared_link_type.dart'; +part 'model/shared_links_response.dart'; +part 'model/shared_links_update.dart'; part 'model/sign_up_dto.dart'; part 'model/smart_search_dto.dart'; part 'model/source_type.dart'; part 'model/stack_create_dto.dart'; part 'model/stack_response_dto.dart'; part 'model/stack_update_dto.dart'; +part 'model/sync_ack_delete_dto.dart'; +part 'model/sync_ack_dto.dart'; +part 'model/sync_ack_set_dto.dart'; +part 'model/sync_asset_delete_v1.dart'; +part 'model/sync_asset_exif_v1.dart'; +part 'model/sync_asset_v1.dart'; +part 'model/sync_entity_type.dart'; +part 'model/sync_partner_delete_v1.dart'; +part 'model/sync_partner_v1.dart'; +part 'model/sync_request_type.dart'; +part 'model/sync_stream_dto.dart'; +part 'model/sync_user_delete_v1.dart'; +part 'model/sync_user_v1.dart'; part 'model/system_config_backups_dto.dart'; part 'model/system_config_dto.dart'; part 'model/system_config_f_fmpeg_dto.dart'; diff --git a/mobile/openapi/lib/api/activities_api.dart b/mobile/openapi/lib/api/activities_api.dart index e5075eee16..5c83ba7db9 100644 --- a/mobile/openapi/lib/api/activities_api.dart +++ b/mobile/openapi/lib/api/activities_api.dart @@ -22,7 +22,7 @@ class ActivitiesApi { /// * [ActivityCreateDto] activityCreateDto (required): Future createActivityWithHttpInfo(ActivityCreateDto activityCreateDto,) async { // ignore: prefer_const_declarations - final path = r'/activities'; + final apiPath = r'/activities'; // ignore: prefer_final_locals Object? postBody = activityCreateDto; @@ -35,7 +35,7 @@ class ActivitiesApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -69,7 +69,7 @@ class ActivitiesApi { /// * [String] id (required): Future deleteActivityWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/activities/{id}' + final apiPath = r'/activities/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -83,7 +83,7 @@ class ActivitiesApi { return apiClient.invokeAPI( - path, + apiPath, 'DELETE', queryParams, postBody, @@ -117,7 +117,7 @@ class ActivitiesApi { /// * [String] userId: Future getActivitiesWithHttpInfo(String albumId, { String? assetId, ReactionLevel? level, ReactionType? type, String? userId, }) async { // ignore: prefer_const_declarations - final path = r'/activities'; + final apiPath = r'/activities'; // ignore: prefer_final_locals Object? postBody; @@ -144,7 +144,7 @@ class ActivitiesApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -191,7 +191,7 @@ class ActivitiesApi { /// * [String] assetId: Future getActivityStatisticsWithHttpInfo(String albumId, { String? assetId, }) async { // ignore: prefer_const_declarations - final path = r'/activities/statistics'; + final apiPath = r'/activities/statistics'; // ignore: prefer_final_locals Object? postBody; @@ -209,7 +209,7 @@ class ActivitiesApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, diff --git a/mobile/openapi/lib/api/albums_api.dart b/mobile/openapi/lib/api/albums_api.dart index eb2bb7c0bd..a8c518ace2 100644 --- a/mobile/openapi/lib/api/albums_api.dart +++ b/mobile/openapi/lib/api/albums_api.dart @@ -26,7 +26,7 @@ class AlbumsApi { /// * [String] key: Future addAssetsToAlbumWithHttpInfo(String id, BulkIdsDto bulkIdsDto, { String? key, }) async { // ignore: prefer_const_declarations - final path = r'/albums/{id}/assets' + final apiPath = r'/albums/{id}/assets' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -44,7 +44,7 @@ class AlbumsApi { return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, @@ -87,7 +87,7 @@ class AlbumsApi { /// * [AddUsersDto] addUsersDto (required): Future addUsersToAlbumWithHttpInfo(String id, AddUsersDto addUsersDto,) async { // ignore: prefer_const_declarations - final path = r'/albums/{id}/users' + final apiPath = r'/albums/{id}/users' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -101,7 +101,7 @@ class AlbumsApi { return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, @@ -137,7 +137,7 @@ class AlbumsApi { /// * [CreateAlbumDto] createAlbumDto (required): Future createAlbumWithHttpInfo(CreateAlbumDto createAlbumDto,) async { // ignore: prefer_const_declarations - final path = r'/albums'; + final apiPath = r'/albums'; // ignore: prefer_final_locals Object? postBody = createAlbumDto; @@ -150,7 +150,7 @@ class AlbumsApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -184,7 +184,7 @@ class AlbumsApi { /// * [String] id (required): Future deleteAlbumWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/albums/{id}' + final apiPath = r'/albums/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -198,7 +198,7 @@ class AlbumsApi { return apiClient.invokeAPI( - path, + apiPath, 'DELETE', queryParams, postBody, @@ -228,7 +228,7 @@ class AlbumsApi { /// * [bool] withoutAssets: Future getAlbumInfoWithHttpInfo(String id, { String? key, bool? withoutAssets, }) async { // ignore: prefer_const_declarations - final path = r'/albums/{id}' + final apiPath = r'/albums/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -249,7 +249,7 @@ class AlbumsApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -284,7 +284,7 @@ class AlbumsApi { /// Performs an HTTP 'GET /albums/statistics' operation and returns the [Response]. Future getAlbumStatisticsWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/albums/statistics'; + final apiPath = r'/albums/statistics'; // ignore: prefer_final_locals Object? postBody; @@ -297,7 +297,7 @@ class AlbumsApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -331,7 +331,7 @@ class AlbumsApi { /// * [bool] shared: Future getAllAlbumsWithHttpInfo({ String? assetId, bool? shared, }) async { // ignore: prefer_const_declarations - final path = r'/albums'; + final apiPath = r'/albums'; // ignore: prefer_final_locals Object? postBody; @@ -351,7 +351,7 @@ class AlbumsApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -393,7 +393,7 @@ class AlbumsApi { /// * [BulkIdsDto] bulkIdsDto (required): Future removeAssetFromAlbumWithHttpInfo(String id, BulkIdsDto bulkIdsDto,) async { // ignore: prefer_const_declarations - final path = r'/albums/{id}/assets' + final apiPath = r'/albums/{id}/assets' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -407,7 +407,7 @@ class AlbumsApi { return apiClient.invokeAPI( - path, + apiPath, 'DELETE', queryParams, postBody, @@ -448,7 +448,7 @@ class AlbumsApi { /// * [String] userId (required): Future removeUserFromAlbumWithHttpInfo(String id, String userId,) async { // ignore: prefer_const_declarations - final path = r'/albums/{id}/user/{userId}' + final apiPath = r'/albums/{id}/user/{userId}' .replaceAll('{id}', id) .replaceAll('{userId}', userId); @@ -463,7 +463,7 @@ class AlbumsApi { return apiClient.invokeAPI( - path, + apiPath, 'DELETE', queryParams, postBody, @@ -493,7 +493,7 @@ class AlbumsApi { /// * [UpdateAlbumDto] updateAlbumDto (required): Future updateAlbumInfoWithHttpInfo(String id, UpdateAlbumDto updateAlbumDto,) async { // ignore: prefer_const_declarations - final path = r'/albums/{id}' + final apiPath = r'/albums/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -507,7 +507,7 @@ class AlbumsApi { return apiClient.invokeAPI( - path, + apiPath, 'PATCH', queryParams, postBody, @@ -547,7 +547,7 @@ class AlbumsApi { /// * [UpdateAlbumUserDto] updateAlbumUserDto (required): Future updateAlbumUserWithHttpInfo(String id, String userId, UpdateAlbumUserDto updateAlbumUserDto,) async { // ignore: prefer_const_declarations - final path = r'/albums/{id}/user/{userId}' + final apiPath = r'/albums/{id}/user/{userId}' .replaceAll('{id}', id) .replaceAll('{userId}', userId); @@ -562,7 +562,7 @@ class AlbumsApi { return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, diff --git a/mobile/openapi/lib/api/api_keys_api.dart b/mobile/openapi/lib/api/api_keys_api.dart index 2e7757f20a..cf54ac5c04 100644 --- a/mobile/openapi/lib/api/api_keys_api.dart +++ b/mobile/openapi/lib/api/api_keys_api.dart @@ -22,7 +22,7 @@ class APIKeysApi { /// * [APIKeyCreateDto] aPIKeyCreateDto (required): Future createApiKeyWithHttpInfo(APIKeyCreateDto aPIKeyCreateDto,) async { // ignore: prefer_const_declarations - final path = r'/api-keys'; + final apiPath = r'/api-keys'; // ignore: prefer_final_locals Object? postBody = aPIKeyCreateDto; @@ -35,7 +35,7 @@ class APIKeysApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -69,7 +69,7 @@ class APIKeysApi { /// * [String] id (required): Future deleteApiKeyWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/api-keys/{id}' + final apiPath = r'/api-keys/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -83,7 +83,7 @@ class APIKeysApi { return apiClient.invokeAPI( - path, + apiPath, 'DELETE', queryParams, postBody, @@ -109,7 +109,7 @@ class APIKeysApi { /// * [String] id (required): Future getApiKeyWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/api-keys/{id}' + final apiPath = r'/api-keys/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -123,7 +123,7 @@ class APIKeysApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -154,7 +154,7 @@ class APIKeysApi { /// Performs an HTTP 'GET /api-keys' operation and returns the [Response]. Future getApiKeysWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/api-keys'; + final apiPath = r'/api-keys'; // ignore: prefer_final_locals Object? postBody; @@ -167,7 +167,7 @@ class APIKeysApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -203,7 +203,7 @@ class APIKeysApi { /// * [APIKeyUpdateDto] aPIKeyUpdateDto (required): Future updateApiKeyWithHttpInfo(String id, APIKeyUpdateDto aPIKeyUpdateDto,) async { // ignore: prefer_const_declarations - final path = r'/api-keys/{id}' + final apiPath = r'/api-keys/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -217,7 +217,7 @@ class APIKeysApi { return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index fd89986980..f52c70b37f 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -16,6 +16,8 @@ class AssetsApi { final ApiClient apiClient; + /// checkBulkUpload + /// /// Checks if assets exist by checksums /// /// Note: This method returns the HTTP [Response]. @@ -25,7 +27,7 @@ class AssetsApi { /// * [AssetBulkUploadCheckDto] assetBulkUploadCheckDto (required): Future checkBulkUploadWithHttpInfo(AssetBulkUploadCheckDto assetBulkUploadCheckDto,) async { // ignore: prefer_const_declarations - final path = r'/assets/bulk-upload-check'; + final apiPath = r'/assets/bulk-upload-check'; // ignore: prefer_final_locals Object? postBody = assetBulkUploadCheckDto; @@ -38,7 +40,7 @@ class AssetsApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -48,6 +50,8 @@ class AssetsApi { ); } + /// checkBulkUpload + /// /// Checks if assets exist by checksums /// /// Parameters: @@ -68,6 +72,8 @@ class AssetsApi { return null; } + /// checkExistingAssets + /// /// Checks if multiple assets exist on the server and returns all existing - used by background backup /// /// Note: This method returns the HTTP [Response]. @@ -77,7 +83,7 @@ class AssetsApi { /// * [CheckExistingAssetsDto] checkExistingAssetsDto (required): Future checkExistingAssetsWithHttpInfo(CheckExistingAssetsDto checkExistingAssetsDto,) async { // ignore: prefer_const_declarations - final path = r'/assets/exist'; + final apiPath = r'/assets/exist'; // ignore: prefer_final_locals Object? postBody = checkExistingAssetsDto; @@ -90,7 +96,7 @@ class AssetsApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -100,6 +106,8 @@ class AssetsApi { ); } + /// checkExistingAssets + /// /// Checks if multiple assets exist on the server and returns all existing - used by background backup /// /// Parameters: @@ -126,7 +134,7 @@ class AssetsApi { /// * [AssetBulkDeleteDto] assetBulkDeleteDto (required): Future deleteAssetsWithHttpInfo(AssetBulkDeleteDto assetBulkDeleteDto,) async { // ignore: prefer_const_declarations - final path = r'/assets'; + final apiPath = r'/assets'; // ignore: prefer_final_locals Object? postBody = assetBulkDeleteDto; @@ -139,7 +147,7 @@ class AssetsApi { return apiClient.invokeAPI( - path, + apiPath, 'DELETE', queryParams, postBody, @@ -167,7 +175,7 @@ class AssetsApi { /// * [String] key: Future downloadAssetWithHttpInfo(String id, { String? key, }) async { // ignore: prefer_const_declarations - final path = r'/assets/{id}/original' + final apiPath = r'/assets/{id}/original' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -185,7 +193,7 @@ class AssetsApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -215,6 +223,8 @@ class AssetsApi { return null; } + /// getAllUserAssetsByDeviceId + /// /// Get all asset of a device that are in the database, ID only. /// /// Note: This method returns the HTTP [Response]. @@ -224,7 +234,7 @@ class AssetsApi { /// * [String] deviceId (required): Future getAllUserAssetsByDeviceIdWithHttpInfo(String deviceId,) async { // ignore: prefer_const_declarations - final path = r'/assets/device/{deviceId}' + final apiPath = r'/assets/device/{deviceId}' .replaceAll('{deviceId}', deviceId); // ignore: prefer_final_locals @@ -238,7 +248,7 @@ class AssetsApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -248,6 +258,8 @@ class AssetsApi { ); } + /// getAllUserAssetsByDeviceId + /// /// Get all asset of a device that are in the database, ID only. /// /// Parameters: @@ -279,7 +291,7 @@ class AssetsApi { /// * [String] key: Future getAssetInfoWithHttpInfo(String id, { String? key, }) async { // ignore: prefer_const_declarations - final path = r'/assets/{id}' + final apiPath = r'/assets/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -297,7 +309,7 @@ class AssetsApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -337,7 +349,7 @@ class AssetsApi { /// * [bool] isTrashed: Future getAssetStatisticsWithHttpInfo({ bool? isArchived, bool? isFavorite, bool? isTrashed, }) async { // ignore: prefer_const_declarations - final path = r'/assets/statistics'; + final apiPath = r'/assets/statistics'; // ignore: prefer_final_locals Object? postBody; @@ -360,7 +372,7 @@ class AssetsApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -400,7 +412,7 @@ class AssetsApi { /// * [int] month (required): Future getMemoryLaneWithHttpInfo(int day, int month,) async { // ignore: prefer_const_declarations - final path = r'/assets/memory-lane'; + final apiPath = r'/assets/memory-lane'; // ignore: prefer_final_locals Object? postBody; @@ -416,7 +428,7 @@ class AssetsApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -458,7 +470,7 @@ class AssetsApi { /// * [num] count: Future getRandomWithHttpInfo({ num? count, }) async { // ignore: prefer_const_declarations - final path = r'/assets/random'; + final apiPath = r'/assets/random'; // ignore: prefer_final_locals Object? postBody; @@ -475,7 +487,7 @@ class AssetsApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -516,7 +528,7 @@ class AssetsApi { /// * [String] key: Future playAssetVideoWithHttpInfo(String id, { String? key, }) async { // ignore: prefer_const_declarations - final path = r'/assets/{id}/video/playback' + final apiPath = r'/assets/{id}/video/playback' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -534,7 +546,7 @@ class AssetsApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -564,6 +576,8 @@ class AssetsApi { return null; } + /// replaceAsset + /// /// Replace the asset with new file, without changing its id /// /// Note: This method returns the HTTP [Response]. @@ -587,7 +601,7 @@ class AssetsApi { /// * [String] duration: Future replaceAssetWithHttpInfo(String id, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? duration, }) async { // ignore: prefer_const_declarations - final path = r'/assets/{id}/original' + final apiPath = r'/assets/{id}/original' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -604,7 +618,7 @@ class AssetsApi { const contentTypes = ['multipart/form-data']; bool hasFields = false; - final mp = MultipartRequest('PUT', Uri.parse(path)); + final mp = MultipartRequest('PUT', Uri.parse(apiPath)); if (assetData != null) { hasFields = true; mp.fields[r'assetData'] = assetData.field; @@ -635,7 +649,7 @@ class AssetsApi { } return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, @@ -645,6 +659,8 @@ class AssetsApi { ); } + /// replaceAsset + /// /// Replace the asset with new file, without changing its id /// /// Parameters: @@ -685,7 +701,7 @@ class AssetsApi { /// * [AssetJobsDto] assetJobsDto (required): Future runAssetJobsWithHttpInfo(AssetJobsDto assetJobsDto,) async { // ignore: prefer_const_declarations - final path = r'/assets/jobs'; + final apiPath = r'/assets/jobs'; // ignore: prefer_final_locals Object? postBody = assetJobsDto; @@ -698,7 +714,7 @@ class AssetsApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -726,7 +742,7 @@ class AssetsApi { /// * [UpdateAssetDto] updateAssetDto (required): Future updateAssetWithHttpInfo(String id, UpdateAssetDto updateAssetDto,) async { // ignore: prefer_const_declarations - final path = r'/assets/{id}' + final apiPath = r'/assets/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -740,7 +756,7 @@ class AssetsApi { return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, @@ -776,7 +792,7 @@ class AssetsApi { /// * [AssetBulkUpdateDto] assetBulkUpdateDto (required): Future updateAssetsWithHttpInfo(AssetBulkUpdateDto assetBulkUpdateDto,) async { // ignore: prefer_const_declarations - final path = r'/assets'; + final apiPath = r'/assets'; // ignore: prefer_final_locals Object? postBody = assetBulkUpdateDto; @@ -789,7 +805,7 @@ class AssetsApi { return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, @@ -840,7 +856,7 @@ class AssetsApi { /// * [MultipartFile] sidecarData: Future uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async { // ignore: prefer_const_declarations - final path = r'/assets'; + final apiPath = r'/assets'; // ignore: prefer_final_locals Object? postBody; @@ -860,7 +876,7 @@ class AssetsApi { const contentTypes = ['multipart/form-data']; bool hasFields = false; - final mp = MultipartRequest('POST', Uri.parse(path)); + final mp = MultipartRequest('POST', Uri.parse(apiPath)); if (assetData != null) { hasFields = true; mp.fields[r'assetData'] = assetData.field; @@ -912,7 +928,7 @@ class AssetsApi { } return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -975,7 +991,7 @@ class AssetsApi { /// * [AssetMediaSize] size: Future viewAssetWithHttpInfo(String id, { String? key, AssetMediaSize? size, }) async { // ignore: prefer_const_declarations - final path = r'/assets/{id}/thumbnail' + final apiPath = r'/assets/{id}/thumbnail' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -996,7 +1012,7 @@ class AssetsApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, diff --git a/mobile/openapi/lib/api/audit_api.dart b/mobile/openapi/lib/api/audit_api.dart deleted file mode 100644 index f6d71eafdb..0000000000 --- a/mobile/openapi/lib/api/audit_api.dart +++ /dev/null @@ -1,79 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - - -class AuditApi { - AuditApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; - - final ApiClient apiClient; - - /// Performs an HTTP 'GET /audit/deletes' operation and returns the [Response]. - /// Parameters: - /// - /// * [DateTime] after (required): - /// - /// * [EntityType] entityType (required): - /// - /// * [String] userId: - Future getAuditDeletesWithHttpInfo(DateTime after, EntityType entityType, { String? userId, }) async { - // ignore: prefer_const_declarations - final path = r'/audit/deletes'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - queryParams.addAll(_queryParams('', 'after', after)); - queryParams.addAll(_queryParams('', 'entityType', entityType)); - if (userId != null) { - queryParams.addAll(_queryParams('', 'userId', userId)); - } - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [DateTime] after (required): - /// - /// * [EntityType] entityType (required): - /// - /// * [String] userId: - Future getAuditDeletes(DateTime after, EntityType entityType, { String? userId, }) async { - final response = await getAuditDeletesWithHttpInfo(after, entityType, userId: userId, ); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AuditDeletesResponseDto',) as AuditDeletesResponseDto; - - } - return null; - } -} diff --git a/mobile/openapi/lib/api/authentication_api.dart b/mobile/openapi/lib/api/authentication_api.dart index cb81867425..bf987f441e 100644 --- a/mobile/openapi/lib/api/authentication_api.dart +++ b/mobile/openapi/lib/api/authentication_api.dart @@ -22,7 +22,7 @@ class AuthenticationApi { /// * [ChangePasswordDto] changePasswordDto (required): Future changePasswordWithHttpInfo(ChangePasswordDto changePasswordDto,) async { // ignore: prefer_const_declarations - final path = r'/auth/change-password'; + final apiPath = r'/auth/change-password'; // ignore: prefer_final_locals Object? postBody = changePasswordDto; @@ -35,7 +35,7 @@ class AuthenticationApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -69,7 +69,7 @@ class AuthenticationApi { /// * [LoginCredentialDto] loginCredentialDto (required): Future loginWithHttpInfo(LoginCredentialDto loginCredentialDto,) async { // ignore: prefer_const_declarations - final path = r'/auth/login'; + final apiPath = r'/auth/login'; // ignore: prefer_final_locals Object? postBody = loginCredentialDto; @@ -82,7 +82,7 @@ class AuthenticationApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -113,7 +113,7 @@ class AuthenticationApi { /// Performs an HTTP 'POST /auth/logout' operation and returns the [Response]. Future logoutWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/auth/logout'; + final apiPath = r'/auth/logout'; // ignore: prefer_final_locals Object? postBody; @@ -126,7 +126,7 @@ class AuthenticationApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -157,7 +157,7 @@ class AuthenticationApi { /// * [SignUpDto] signUpDto (required): Future signUpAdminWithHttpInfo(SignUpDto signUpDto,) async { // ignore: prefer_const_declarations - final path = r'/auth/admin-sign-up'; + final apiPath = r'/auth/admin-sign-up'; // ignore: prefer_final_locals Object? postBody = signUpDto; @@ -170,7 +170,7 @@ class AuthenticationApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -201,7 +201,7 @@ class AuthenticationApi { /// Performs an HTTP 'POST /auth/validateToken' operation and returns the [Response]. Future validateAccessTokenWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/auth/validateToken'; + final apiPath = r'/auth/validateToken'; // ignore: prefer_final_locals Object? postBody; @@ -214,7 +214,7 @@ class AuthenticationApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, diff --git a/mobile/openapi/lib/api/deprecated_api.dart b/mobile/openapi/lib/api/deprecated_api.dart index 30e35b451c..7aa9662c23 100644 --- a/mobile/openapi/lib/api/deprecated_api.dart +++ b/mobile/openapi/lib/api/deprecated_api.dart @@ -25,7 +25,7 @@ class DeprecatedApi { /// * [num] count: Future getRandomWithHttpInfo({ num? count, }) async { // ignore: prefer_const_declarations - final path = r'/assets/random'; + final apiPath = r'/assets/random'; // ignore: prefer_final_locals Object? postBody; @@ -42,7 +42,7 @@ class DeprecatedApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, diff --git a/mobile/openapi/lib/api/download_api.dart b/mobile/openapi/lib/api/download_api.dart index b89f340ec7..3b11c2f630 100644 --- a/mobile/openapi/lib/api/download_api.dart +++ b/mobile/openapi/lib/api/download_api.dart @@ -24,7 +24,7 @@ class DownloadApi { /// * [String] key: Future downloadArchiveWithHttpInfo(AssetIdsDto assetIdsDto, { String? key, }) async { // ignore: prefer_const_declarations - final path = r'/download/archive'; + final apiPath = r'/download/archive'; // ignore: prefer_final_locals Object? postBody = assetIdsDto; @@ -41,7 +41,7 @@ class DownloadApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -79,7 +79,7 @@ class DownloadApi { /// * [String] key: Future getDownloadInfoWithHttpInfo(DownloadInfoDto downloadInfoDto, { String? key, }) async { // ignore: prefer_const_declarations - final path = r'/download/info'; + final apiPath = r'/download/info'; // ignore: prefer_final_locals Object? postBody = downloadInfoDto; @@ -96,7 +96,7 @@ class DownloadApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, diff --git a/mobile/openapi/lib/api/duplicates_api.dart b/mobile/openapi/lib/api/duplicates_api.dart index b82290e47b..715c6d6112 100644 --- a/mobile/openapi/lib/api/duplicates_api.dart +++ b/mobile/openapi/lib/api/duplicates_api.dart @@ -19,7 +19,7 @@ class DuplicatesApi { /// Performs an HTTP 'GET /duplicates' operation and returns the [Response]. Future getAssetDuplicatesWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/duplicates'; + final apiPath = r'/duplicates'; // ignore: prefer_final_locals Object? postBody; @@ -32,7 +32,7 @@ class DuplicatesApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, diff --git a/mobile/openapi/lib/api/faces_api.dart b/mobile/openapi/lib/api/faces_api.dart index addda0a7a3..44e3d53f8e 100644 --- a/mobile/openapi/lib/api/faces_api.dart +++ b/mobile/openapi/lib/api/faces_api.dart @@ -16,13 +16,96 @@ class FacesApi { final ApiClient apiClient; + /// Performs an HTTP 'POST /faces' operation and returns the [Response]. + /// Parameters: + /// + /// * [AssetFaceCreateDto] assetFaceCreateDto (required): + Future createFaceWithHttpInfo(AssetFaceCreateDto assetFaceCreateDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/faces'; + + // ignore: prefer_final_locals + Object? postBody = assetFaceCreateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [AssetFaceCreateDto] assetFaceCreateDto (required): + Future createFace(AssetFaceCreateDto assetFaceCreateDto,) async { + final response = await createFaceWithHttpInfo(assetFaceCreateDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Performs an HTTP 'DELETE /faces/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [AssetFaceDeleteDto] assetFaceDeleteDto (required): + Future deleteFaceWithHttpInfo(String id, AssetFaceDeleteDto assetFaceDeleteDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/faces/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody = assetFaceDeleteDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [AssetFaceDeleteDto] assetFaceDeleteDto (required): + Future deleteFace(String id, AssetFaceDeleteDto assetFaceDeleteDto,) async { + final response = await deleteFaceWithHttpInfo(id, assetFaceDeleteDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Performs an HTTP 'GET /faces' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): Future getFacesWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/faces'; + final apiPath = r'/faces'; // ignore: prefer_final_locals Object? postBody; @@ -37,7 +120,7 @@ class FacesApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -76,7 +159,7 @@ class FacesApi { /// * [FaceDto] faceDto (required): Future reassignFacesByIdWithHttpInfo(String id, FaceDto faceDto,) async { // ignore: prefer_const_declarations - final path = r'/faces/{id}' + final apiPath = r'/faces/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -90,7 +173,7 @@ class FacesApi { return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, diff --git a/mobile/openapi/lib/api/file_reports_api.dart b/mobile/openapi/lib/api/file_reports_api.dart index 5eab91576e..73b3feaedb 100644 --- a/mobile/openapi/lib/api/file_reports_api.dart +++ b/mobile/openapi/lib/api/file_reports_api.dart @@ -22,7 +22,7 @@ class FileReportsApi { /// * [FileReportFixDto] fileReportFixDto (required): Future fixAuditFilesWithHttpInfo(FileReportFixDto fileReportFixDto,) async { // ignore: prefer_const_declarations - final path = r'/reports/fix'; + final apiPath = r'/reports/fix'; // ignore: prefer_final_locals Object? postBody = fileReportFixDto; @@ -35,7 +35,7 @@ class FileReportsApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -58,7 +58,7 @@ class FileReportsApi { /// Performs an HTTP 'GET /reports' operation and returns the [Response]. Future getAuditFilesWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/reports'; + final apiPath = r'/reports'; // ignore: prefer_final_locals Object? postBody; @@ -71,7 +71,7 @@ class FileReportsApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -102,7 +102,7 @@ class FileReportsApi { /// * [FileChecksumDto] fileChecksumDto (required): Future getFileChecksumsWithHttpInfo(FileChecksumDto fileChecksumDto,) async { // ignore: prefer_const_declarations - final path = r'/reports/checksum'; + final apiPath = r'/reports/checksum'; // ignore: prefer_final_locals Object? postBody = fileChecksumDto; @@ -115,7 +115,7 @@ class FileReportsApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, diff --git a/mobile/openapi/lib/api/jobs_api.dart b/mobile/openapi/lib/api/jobs_api.dart index 78afc15c93..182bb14e4f 100644 --- a/mobile/openapi/lib/api/jobs_api.dart +++ b/mobile/openapi/lib/api/jobs_api.dart @@ -22,7 +22,7 @@ class JobsApi { /// * [JobCreateDto] jobCreateDto (required): Future createJobWithHttpInfo(JobCreateDto jobCreateDto,) async { // ignore: prefer_const_declarations - final path = r'/jobs'; + final apiPath = r'/jobs'; // ignore: prefer_final_locals Object? postBody = jobCreateDto; @@ -35,7 +35,7 @@ class JobsApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -58,7 +58,7 @@ class JobsApi { /// Performs an HTTP 'GET /jobs' operation and returns the [Response]. Future getAllJobsStatusWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/jobs'; + final apiPath = r'/jobs'; // ignore: prefer_final_locals Object? postBody; @@ -71,7 +71,7 @@ class JobsApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -104,7 +104,7 @@ class JobsApi { /// * [JobCommandDto] jobCommandDto (required): Future sendJobCommandWithHttpInfo(JobName id, JobCommandDto jobCommandDto,) async { // ignore: prefer_const_declarations - final path = r'/jobs/{id}' + final apiPath = r'/jobs/{id}' .replaceAll('{id}', id.toString()); // ignore: prefer_final_locals @@ -118,7 +118,7 @@ class JobsApi { return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, diff --git a/mobile/openapi/lib/api/libraries_api.dart b/mobile/openapi/lib/api/libraries_api.dart index 36d98d9a88..86acce76b4 100644 --- a/mobile/openapi/lib/api/libraries_api.dart +++ b/mobile/openapi/lib/api/libraries_api.dart @@ -22,7 +22,7 @@ class LibrariesApi { /// * [CreateLibraryDto] createLibraryDto (required): Future createLibraryWithHttpInfo(CreateLibraryDto createLibraryDto,) async { // ignore: prefer_const_declarations - final path = r'/libraries'; + final apiPath = r'/libraries'; // ignore: prefer_final_locals Object? postBody = createLibraryDto; @@ -35,7 +35,7 @@ class LibrariesApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -69,7 +69,7 @@ class LibrariesApi { /// * [String] id (required): Future deleteLibraryWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/libraries/{id}' + final apiPath = r'/libraries/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -83,7 +83,7 @@ class LibrariesApi { return apiClient.invokeAPI( - path, + apiPath, 'DELETE', queryParams, postBody, @@ -106,7 +106,7 @@ class LibrariesApi { /// Performs an HTTP 'GET /libraries' operation and returns the [Response]. Future getAllLibrariesWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/libraries'; + final apiPath = r'/libraries'; // ignore: prefer_final_locals Object? postBody; @@ -119,7 +119,7 @@ class LibrariesApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -153,7 +153,7 @@ class LibrariesApi { /// * [String] id (required): Future getLibraryWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/libraries/{id}' + final apiPath = r'/libraries/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -167,7 +167,7 @@ class LibrariesApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -201,7 +201,7 @@ class LibrariesApi { /// * [String] id (required): Future getLibraryStatisticsWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/libraries/{id}/statistics' + final apiPath = r'/libraries/{id}/statistics' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -215,7 +215,7 @@ class LibrariesApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -249,7 +249,7 @@ class LibrariesApi { /// * [String] id (required): Future scanLibraryWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/libraries/{id}/scan' + final apiPath = r'/libraries/{id}/scan' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -263,7 +263,7 @@ class LibrariesApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -291,7 +291,7 @@ class LibrariesApi { /// * [UpdateLibraryDto] updateLibraryDto (required): Future updateLibraryWithHttpInfo(String id, UpdateLibraryDto updateLibraryDto,) async { // ignore: prefer_const_declarations - final path = r'/libraries/{id}' + final apiPath = r'/libraries/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -305,7 +305,7 @@ class LibrariesApi { return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, @@ -343,7 +343,7 @@ class LibrariesApi { /// * [ValidateLibraryDto] validateLibraryDto (required): Future validateWithHttpInfo(String id, ValidateLibraryDto validateLibraryDto,) async { // ignore: prefer_const_declarations - final path = r'/libraries/{id}/validate' + final apiPath = r'/libraries/{id}/validate' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -357,7 +357,7 @@ class LibrariesApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, diff --git a/mobile/openapi/lib/api/map_api.dart b/mobile/openapi/lib/api/map_api.dart index 9644fbfc5c..ffe72df453 100644 --- a/mobile/openapi/lib/api/map_api.dart +++ b/mobile/openapi/lib/api/map_api.dart @@ -32,7 +32,7 @@ class MapApi { /// * [bool] withSharedAlbums: Future getMapMarkersWithHttpInfo({ DateTime? fileCreatedAfter, DateTime? fileCreatedBefore, bool? isArchived, bool? isFavorite, bool? withPartners, bool? withSharedAlbums, }) async { // ignore: prefer_const_declarations - final path = r'/map/markers'; + final apiPath = r'/map/markers'; // ignore: prefer_final_locals Object? postBody; @@ -64,7 +64,7 @@ class MapApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -113,7 +113,7 @@ class MapApi { /// * [double] lon (required): Future reverseGeocodeWithHttpInfo(double lat, double lon,) async { // ignore: prefer_const_declarations - final path = r'/map/reverse-geocode'; + final apiPath = r'/map/reverse-geocode'; // ignore: prefer_final_locals Object? postBody; @@ -129,7 +129,7 @@ class MapApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, diff --git a/mobile/openapi/lib/api/memories_api.dart b/mobile/openapi/lib/api/memories_api.dart index 5f77a2a34e..88897d3038 100644 --- a/mobile/openapi/lib/api/memories_api.dart +++ b/mobile/openapi/lib/api/memories_api.dart @@ -24,7 +24,7 @@ class MemoriesApi { /// * [BulkIdsDto] bulkIdsDto (required): Future addMemoryAssetsWithHttpInfo(String id, BulkIdsDto bulkIdsDto,) async { // ignore: prefer_const_declarations - final path = r'/memories/{id}/assets' + final apiPath = r'/memories/{id}/assets' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -38,7 +38,7 @@ class MemoriesApi { return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, @@ -77,7 +77,7 @@ class MemoriesApi { /// * [MemoryCreateDto] memoryCreateDto (required): Future createMemoryWithHttpInfo(MemoryCreateDto memoryCreateDto,) async { // ignore: prefer_const_declarations - final path = r'/memories'; + final apiPath = r'/memories'; // ignore: prefer_final_locals Object? postBody = memoryCreateDto; @@ -90,7 +90,7 @@ class MemoriesApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -124,7 +124,7 @@ class MemoriesApi { /// * [String] id (required): Future deleteMemoryWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/memories/{id}' + final apiPath = r'/memories/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -138,7 +138,7 @@ class MemoriesApi { return apiClient.invokeAPI( - path, + apiPath, 'DELETE', queryParams, postBody, @@ -164,7 +164,7 @@ class MemoriesApi { /// * [String] id (required): Future getMemoryWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/memories/{id}' + final apiPath = r'/memories/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -178,7 +178,7 @@ class MemoriesApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -214,7 +214,7 @@ class MemoriesApi { /// * [BulkIdsDto] bulkIdsDto (required): Future removeMemoryAssetsWithHttpInfo(String id, BulkIdsDto bulkIdsDto,) async { // ignore: prefer_const_declarations - final path = r'/memories/{id}/assets' + final apiPath = r'/memories/{id}/assets' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -228,7 +228,7 @@ class MemoriesApi { return apiClient.invokeAPI( - path, + apiPath, 'DELETE', queryParams, postBody, @@ -262,9 +262,18 @@ class MemoriesApi { } /// Performs an HTTP 'GET /memories' operation and returns the [Response]. - Future searchMemoriesWithHttpInfo() async { + /// Parameters: + /// + /// * [DateTime] for_: + /// + /// * [bool] isSaved: + /// + /// * [bool] isTrashed: + /// + /// * [MemoryType] type: + Future searchMemoriesWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemoryType? type, }) async { // ignore: prefer_const_declarations - final path = r'/memories'; + final apiPath = r'/memories'; // ignore: prefer_final_locals Object? postBody; @@ -273,11 +282,24 @@ class MemoriesApi { final headerParams = {}; final formParams = {}; + if (for_ != null) { + queryParams.addAll(_queryParams('', 'for', for_)); + } + if (isSaved != null) { + queryParams.addAll(_queryParams('', 'isSaved', isSaved)); + } + if (isTrashed != null) { + queryParams.addAll(_queryParams('', 'isTrashed', isTrashed)); + } + if (type != null) { + queryParams.addAll(_queryParams('', 'type', type)); + } + const contentTypes = []; return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -287,8 +309,17 @@ class MemoriesApi { ); } - Future?> searchMemories() async { - final response = await searchMemoriesWithHttpInfo(); + /// Parameters: + /// + /// * [DateTime] for_: + /// + /// * [bool] isSaved: + /// + /// * [bool] isTrashed: + /// + /// * [MemoryType] type: + Future?> searchMemories({ DateTime? for_, bool? isSaved, bool? isTrashed, MemoryType? type, }) async { + final response = await searchMemoriesWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, type: type, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -313,7 +344,7 @@ class MemoriesApi { /// * [MemoryUpdateDto] memoryUpdateDto (required): Future updateMemoryWithHttpInfo(String id, MemoryUpdateDto memoryUpdateDto,) async { // ignore: prefer_const_declarations - final path = r'/memories/{id}' + final apiPath = r'/memories/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -327,7 +358,7 @@ class MemoriesApi { return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, diff --git a/mobile/openapi/lib/api/notifications_api.dart b/mobile/openapi/lib/api/notifications_api.dart index 323fbcc3d6..518a1baa4a 100644 --- a/mobile/openapi/lib/api/notifications_api.dart +++ b/mobile/openapi/lib/api/notifications_api.dart @@ -24,7 +24,7 @@ class NotificationsApi { /// * [TemplateDto] templateDto (required): Future getNotificationTemplateWithHttpInfo(String name, TemplateDto templateDto,) async { // ignore: prefer_const_declarations - final path = r'/notifications/templates/{name}' + final apiPath = r'/notifications/templates/{name}' .replaceAll('{name}', name); // ignore: prefer_final_locals @@ -38,7 +38,7 @@ class NotificationsApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -74,7 +74,7 @@ class NotificationsApi { /// * [SystemConfigSmtpDto] systemConfigSmtpDto (required): Future sendTestEmailWithHttpInfo(SystemConfigSmtpDto systemConfigSmtpDto,) async { // ignore: prefer_const_declarations - final path = r'/notifications/test-email'; + final apiPath = r'/notifications/test-email'; // ignore: prefer_final_locals Object? postBody = systemConfigSmtpDto; @@ -87,7 +87,7 @@ class NotificationsApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, diff --git a/mobile/openapi/lib/api/o_auth_api.dart b/mobile/openapi/lib/api/o_auth_api.dart index aafcb28461..9f16e37c70 100644 --- a/mobile/openapi/lib/api/o_auth_api.dart +++ b/mobile/openapi/lib/api/o_auth_api.dart @@ -22,7 +22,7 @@ class OAuthApi { /// * [OAuthCallbackDto] oAuthCallbackDto (required): Future finishOAuthWithHttpInfo(OAuthCallbackDto oAuthCallbackDto,) async { // ignore: prefer_const_declarations - final path = r'/oauth/callback'; + final apiPath = r'/oauth/callback'; // ignore: prefer_final_locals Object? postBody = oAuthCallbackDto; @@ -35,7 +35,7 @@ class OAuthApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -69,7 +69,7 @@ class OAuthApi { /// * [OAuthCallbackDto] oAuthCallbackDto (required): Future linkOAuthAccountWithHttpInfo(OAuthCallbackDto oAuthCallbackDto,) async { // ignore: prefer_const_declarations - final path = r'/oauth/link'; + final apiPath = r'/oauth/link'; // ignore: prefer_final_locals Object? postBody = oAuthCallbackDto; @@ -82,7 +82,7 @@ class OAuthApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -113,7 +113,7 @@ class OAuthApi { /// Performs an HTTP 'GET /oauth/mobile-redirect' operation and returns the [Response]. Future redirectOAuthToMobileWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/oauth/mobile-redirect'; + final apiPath = r'/oauth/mobile-redirect'; // ignore: prefer_final_locals Object? postBody; @@ -126,7 +126,7 @@ class OAuthApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -149,7 +149,7 @@ class OAuthApi { /// * [OAuthConfigDto] oAuthConfigDto (required): Future startOAuthWithHttpInfo(OAuthConfigDto oAuthConfigDto,) async { // ignore: prefer_const_declarations - final path = r'/oauth/authorize'; + final apiPath = r'/oauth/authorize'; // ignore: prefer_final_locals Object? postBody = oAuthConfigDto; @@ -162,7 +162,7 @@ class OAuthApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -193,7 +193,7 @@ class OAuthApi { /// Performs an HTTP 'POST /oauth/unlink' operation and returns the [Response]. Future unlinkOAuthAccountWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/oauth/unlink'; + final apiPath = r'/oauth/unlink'; // ignore: prefer_final_locals Object? postBody; @@ -206,7 +206,7 @@ class OAuthApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, diff --git a/mobile/openapi/lib/api/partners_api.dart b/mobile/openapi/lib/api/partners_api.dart index ac0d03054a..9f10ea4d1e 100644 --- a/mobile/openapi/lib/api/partners_api.dart +++ b/mobile/openapi/lib/api/partners_api.dart @@ -22,7 +22,7 @@ class PartnersApi { /// * [String] id (required): Future createPartnerWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/partners/{id}' + final apiPath = r'/partners/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -36,7 +36,7 @@ class PartnersApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -70,7 +70,7 @@ class PartnersApi { /// * [PartnerDirection] direction (required): Future getPartnersWithHttpInfo(PartnerDirection direction,) async { // ignore: prefer_const_declarations - final path = r'/partners'; + final apiPath = r'/partners'; // ignore: prefer_final_locals Object? postBody; @@ -85,7 +85,7 @@ class PartnersApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -122,7 +122,7 @@ class PartnersApi { /// * [String] id (required): Future removePartnerWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/partners/{id}' + final apiPath = r'/partners/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -136,7 +136,7 @@ class PartnersApi { return apiClient.invokeAPI( - path, + apiPath, 'DELETE', queryParams, postBody, @@ -164,7 +164,7 @@ class PartnersApi { /// * [UpdatePartnerDto] updatePartnerDto (required): Future updatePartnerWithHttpInfo(String id, UpdatePartnerDto updatePartnerDto,) async { // ignore: prefer_const_declarations - final path = r'/partners/{id}' + final apiPath = r'/partners/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -178,7 +178,7 @@ class PartnersApi { return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, diff --git a/mobile/openapi/lib/api/people_api.dart b/mobile/openapi/lib/api/people_api.dart index 92bd0fdeea..1cdb878852 100644 --- a/mobile/openapi/lib/api/people_api.dart +++ b/mobile/openapi/lib/api/people_api.dart @@ -22,7 +22,7 @@ class PeopleApi { /// * [PersonCreateDto] personCreateDto (required): Future createPersonWithHttpInfo(PersonCreateDto personCreateDto,) async { // ignore: prefer_const_declarations - final path = r'/people'; + final apiPath = r'/people'; // ignore: prefer_final_locals Object? postBody = personCreateDto; @@ -35,7 +35,7 @@ class PeopleApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -79,7 +79,7 @@ class PeopleApi { /// * [bool] withHidden: Future getAllPeopleWithHttpInfo({ String? closestAssetId, String? closestPersonId, num? page, num? size, bool? withHidden, }) async { // ignore: prefer_const_declarations - final path = r'/people'; + final apiPath = r'/people'; // ignore: prefer_final_locals Object? postBody; @@ -108,7 +108,7 @@ class PeopleApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -152,7 +152,7 @@ class PeopleApi { /// * [String] id (required): Future getPersonWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/people/{id}' + final apiPath = r'/people/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -166,7 +166,7 @@ class PeopleApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -200,7 +200,7 @@ class PeopleApi { /// * [String] id (required): Future getPersonStatisticsWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/people/{id}/statistics' + final apiPath = r'/people/{id}/statistics' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -214,7 +214,7 @@ class PeopleApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -248,7 +248,7 @@ class PeopleApi { /// * [String] id (required): Future getPersonThumbnailWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/people/{id}/thumbnail' + final apiPath = r'/people/{id}/thumbnail' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -262,7 +262,7 @@ class PeopleApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -298,7 +298,7 @@ class PeopleApi { /// * [MergePersonDto] mergePersonDto (required): Future mergePersonWithHttpInfo(String id, MergePersonDto mergePersonDto,) async { // ignore: prefer_const_declarations - final path = r'/people/{id}/merge' + final apiPath = r'/people/{id}/merge' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -312,7 +312,7 @@ class PeopleApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -353,7 +353,7 @@ class PeopleApi { /// * [AssetFaceUpdateDto] assetFaceUpdateDto (required): Future reassignFacesWithHttpInfo(String id, AssetFaceUpdateDto assetFaceUpdateDto,) async { // ignore: prefer_const_declarations - final path = r'/people/{id}/reassign' + final apiPath = r'/people/{id}/reassign' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -367,7 +367,7 @@ class PeopleApi { return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, @@ -406,7 +406,7 @@ class PeopleApi { /// * [PeopleUpdateDto] peopleUpdateDto (required): Future updatePeopleWithHttpInfo(PeopleUpdateDto peopleUpdateDto,) async { // ignore: prefer_const_declarations - final path = r'/people'; + final apiPath = r'/people'; // ignore: prefer_final_locals Object? postBody = peopleUpdateDto; @@ -419,7 +419,7 @@ class PeopleApi { return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, @@ -458,7 +458,7 @@ class PeopleApi { /// * [PersonUpdateDto] personUpdateDto (required): Future updatePersonWithHttpInfo(String id, PersonUpdateDto personUpdateDto,) async { // ignore: prefer_const_declarations - final path = r'/people/{id}' + final apiPath = r'/people/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -472,7 +472,7 @@ class PeopleApi { return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 70af3ab0a3..632107ff79 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -19,7 +19,7 @@ class SearchApi { /// Performs an HTTP 'GET /search/cities' operation and returns the [Response]. Future getAssetsByCityWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/search/cities'; + final apiPath = r'/search/cities'; // ignore: prefer_final_locals Object? postBody; @@ -32,7 +32,7 @@ class SearchApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -63,7 +63,7 @@ class SearchApi { /// Performs an HTTP 'GET /search/explore' operation and returns the [Response]. Future getExploreDataWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/search/explore'; + final apiPath = r'/search/explore'; // ignore: prefer_final_locals Object? postBody; @@ -76,7 +76,7 @@ class SearchApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -121,7 +121,7 @@ class SearchApi { /// * [String] state: Future getSearchSuggestionsWithHttpInfo(SearchSuggestionType type, { String? country, bool? includeNull, String? make, String? model, String? state, }) async { // ignore: prefer_const_declarations - final path = r'/search/suggestions'; + final apiPath = r'/search/suggestions'; // ignore: prefer_final_locals Object? postBody; @@ -151,7 +151,7 @@ class SearchApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -199,7 +199,7 @@ class SearchApi { /// * [MetadataSearchDto] metadataSearchDto (required): Future searchAssetsWithHttpInfo(MetadataSearchDto metadataSearchDto,) async { // ignore: prefer_const_declarations - final path = r'/search/metadata'; + final apiPath = r'/search/metadata'; // ignore: prefer_final_locals Object? postBody = metadataSearchDto; @@ -212,7 +212,7 @@ class SearchApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -248,7 +248,7 @@ class SearchApi { /// * [bool] withHidden: Future searchPersonWithHttpInfo(String name, { bool? withHidden, }) async { // ignore: prefer_const_declarations - final path = r'/search/person'; + final apiPath = r'/search/person'; // ignore: prefer_final_locals Object? postBody; @@ -266,7 +266,7 @@ class SearchApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -305,7 +305,7 @@ class SearchApi { /// * [String] name (required): Future searchPlacesWithHttpInfo(String name,) async { // ignore: prefer_const_declarations - final path = r'/search/places'; + final apiPath = r'/search/places'; // ignore: prefer_final_locals Object? postBody; @@ -320,7 +320,7 @@ class SearchApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -357,7 +357,7 @@ class SearchApi { /// * [RandomSearchDto] randomSearchDto (required): Future searchRandomWithHttpInfo(RandomSearchDto randomSearchDto,) async { // ignore: prefer_const_declarations - final path = r'/search/random'; + final apiPath = r'/search/random'; // ignore: prefer_final_locals Object? postBody = randomSearchDto; @@ -370,7 +370,7 @@ class SearchApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -407,7 +407,7 @@ class SearchApi { /// * [SmartSearchDto] smartSearchDto (required): Future searchSmartWithHttpInfo(SmartSearchDto smartSearchDto,) async { // ignore: prefer_const_declarations - final path = r'/search/smart'; + final apiPath = r'/search/smart'; // ignore: prefer_final_locals Object? postBody = smartSearchDto; @@ -420,7 +420,7 @@ class SearchApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, diff --git a/mobile/openapi/lib/api/server_api.dart b/mobile/openapi/lib/api/server_api.dart index 7a832ad61a..629949db32 100644 --- a/mobile/openapi/lib/api/server_api.dart +++ b/mobile/openapi/lib/api/server_api.dart @@ -19,7 +19,7 @@ class ServerApi { /// Performs an HTTP 'DELETE /server/license' operation and returns the [Response]. Future deleteServerLicenseWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/server/license'; + final apiPath = r'/server/license'; // ignore: prefer_final_locals Object? postBody; @@ -32,7 +32,7 @@ class ServerApi { return apiClient.invokeAPI( - path, + apiPath, 'DELETE', queryParams, postBody, @@ -52,7 +52,7 @@ class ServerApi { /// Performs an HTTP 'GET /server/about' operation and returns the [Response]. Future getAboutInfoWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/server/about'; + final apiPath = r'/server/about'; // ignore: prefer_final_locals Object? postBody; @@ -65,7 +65,7 @@ class ServerApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -93,7 +93,7 @@ class ServerApi { /// Performs an HTTP 'GET /server/config' operation and returns the [Response]. Future getServerConfigWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/server/config'; + final apiPath = r'/server/config'; // ignore: prefer_final_locals Object? postBody; @@ -106,7 +106,7 @@ class ServerApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -134,7 +134,7 @@ class ServerApi { /// Performs an HTTP 'GET /server/features' operation and returns the [Response]. Future getServerFeaturesWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/server/features'; + final apiPath = r'/server/features'; // ignore: prefer_final_locals Object? postBody; @@ -147,7 +147,7 @@ class ServerApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -175,7 +175,7 @@ class ServerApi { /// Performs an HTTP 'GET /server/license' operation and returns the [Response]. Future getServerLicenseWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/server/license'; + final apiPath = r'/server/license'; // ignore: prefer_final_locals Object? postBody; @@ -188,7 +188,7 @@ class ServerApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -216,7 +216,7 @@ class ServerApi { /// Performs an HTTP 'GET /server/statistics' operation and returns the [Response]. Future getServerStatisticsWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/server/statistics'; + final apiPath = r'/server/statistics'; // ignore: prefer_final_locals Object? postBody; @@ -229,7 +229,7 @@ class ServerApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -257,7 +257,7 @@ class ServerApi { /// Performs an HTTP 'GET /server/version' operation and returns the [Response]. Future getServerVersionWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/server/version'; + final apiPath = r'/server/version'; // ignore: prefer_final_locals Object? postBody; @@ -270,7 +270,7 @@ class ServerApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -298,7 +298,7 @@ class ServerApi { /// Performs an HTTP 'GET /server/storage' operation and returns the [Response]. Future getStorageWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/server/storage'; + final apiPath = r'/server/storage'; // ignore: prefer_final_locals Object? postBody; @@ -311,7 +311,7 @@ class ServerApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -339,7 +339,7 @@ class ServerApi { /// Performs an HTTP 'GET /server/media-types' operation and returns the [Response]. Future getSupportedMediaTypesWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/server/media-types'; + final apiPath = r'/server/media-types'; // ignore: prefer_final_locals Object? postBody; @@ -352,7 +352,7 @@ class ServerApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -380,7 +380,7 @@ class ServerApi { /// Performs an HTTP 'GET /server/theme' operation and returns the [Response]. Future getThemeWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/server/theme'; + final apiPath = r'/server/theme'; // ignore: prefer_final_locals Object? postBody; @@ -393,7 +393,7 @@ class ServerApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -421,7 +421,7 @@ class ServerApi { /// Performs an HTTP 'GET /server/version-history' operation and returns the [Response]. Future getVersionHistoryWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/server/version-history'; + final apiPath = r'/server/version-history'; // ignore: prefer_final_locals Object? postBody; @@ -434,7 +434,7 @@ class ServerApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -465,7 +465,7 @@ class ServerApi { /// Performs an HTTP 'GET /server/ping' operation and returns the [Response]. Future pingServerWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/server/ping'; + final apiPath = r'/server/ping'; // ignore: prefer_final_locals Object? postBody; @@ -478,7 +478,7 @@ class ServerApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -509,7 +509,7 @@ class ServerApi { /// * [LicenseKeyDto] licenseKeyDto (required): Future setServerLicenseWithHttpInfo(LicenseKeyDto licenseKeyDto,) async { // ignore: prefer_const_declarations - final path = r'/server/license'; + final apiPath = r'/server/license'; // ignore: prefer_final_locals Object? postBody = licenseKeyDto; @@ -522,7 +522,7 @@ class ServerApi { return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, diff --git a/mobile/openapi/lib/api/sessions_api.dart b/mobile/openapi/lib/api/sessions_api.dart index fcc6cb836f..203f801b72 100644 --- a/mobile/openapi/lib/api/sessions_api.dart +++ b/mobile/openapi/lib/api/sessions_api.dart @@ -19,7 +19,7 @@ class SessionsApi { /// Performs an HTTP 'DELETE /sessions' operation and returns the [Response]. Future deleteAllSessionsWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/sessions'; + final apiPath = r'/sessions'; // ignore: prefer_final_locals Object? postBody; @@ -32,7 +32,7 @@ class SessionsApi { return apiClient.invokeAPI( - path, + apiPath, 'DELETE', queryParams, postBody, @@ -55,7 +55,7 @@ class SessionsApi { /// * [String] id (required): Future deleteSessionWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/sessions/{id}' + final apiPath = r'/sessions/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -69,7 +69,7 @@ class SessionsApi { return apiClient.invokeAPI( - path, + apiPath, 'DELETE', queryParams, postBody, @@ -92,7 +92,7 @@ class SessionsApi { /// Performs an HTTP 'GET /sessions' operation and returns the [Response]. Future getSessionsWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/sessions'; + final apiPath = r'/sessions'; // ignore: prefer_final_locals Object? postBody; @@ -105,7 +105,7 @@ class SessionsApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, diff --git a/mobile/openapi/lib/api/shared_links_api.dart b/mobile/openapi/lib/api/shared_links_api.dart index 12e0224999..5bac8988dc 100644 --- a/mobile/openapi/lib/api/shared_links_api.dart +++ b/mobile/openapi/lib/api/shared_links_api.dart @@ -26,7 +26,7 @@ class SharedLinksApi { /// * [String] key: Future addSharedLinkAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto, { String? key, }) async { // ignore: prefer_const_declarations - final path = r'/shared-links/{id}/assets' + final apiPath = r'/shared-links/{id}/assets' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -44,7 +44,7 @@ class SharedLinksApi { return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, @@ -85,7 +85,7 @@ class SharedLinksApi { /// * [SharedLinkCreateDto] sharedLinkCreateDto (required): Future createSharedLinkWithHttpInfo(SharedLinkCreateDto sharedLinkCreateDto,) async { // ignore: prefer_const_declarations - final path = r'/shared-links'; + final apiPath = r'/shared-links'; // ignore: prefer_final_locals Object? postBody = sharedLinkCreateDto; @@ -98,7 +98,7 @@ class SharedLinksApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -127,9 +127,12 @@ class SharedLinksApi { } /// Performs an HTTP 'GET /shared-links' operation and returns the [Response]. - Future getAllSharedLinksWithHttpInfo() async { + /// Parameters: + /// + /// * [String] albumId: + Future getAllSharedLinksWithHttpInfo({ String? albumId, }) async { // ignore: prefer_const_declarations - final path = r'/shared-links'; + final apiPath = r'/shared-links'; // ignore: prefer_final_locals Object? postBody; @@ -138,11 +141,15 @@ class SharedLinksApi { final headerParams = {}; final formParams = {}; + if (albumId != null) { + queryParams.addAll(_queryParams('', 'albumId', albumId)); + } + const contentTypes = []; return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -152,8 +159,11 @@ class SharedLinksApi { ); } - Future?> getAllSharedLinks() async { - final response = await getAllSharedLinksWithHttpInfo(); + /// Parameters: + /// + /// * [String] albumId: + Future?> getAllSharedLinks({ String? albumId, }) async { + final response = await getAllSharedLinksWithHttpInfo( albumId: albumId, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -180,7 +190,7 @@ class SharedLinksApi { /// * [String] token: Future getMySharedLinkWithHttpInfo({ String? key, String? password, String? token, }) async { // ignore: prefer_const_declarations - final path = r'/shared-links/me'; + final apiPath = r'/shared-links/me'; // ignore: prefer_final_locals Object? postBody; @@ -203,7 +213,7 @@ class SharedLinksApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -241,7 +251,7 @@ class SharedLinksApi { /// * [String] id (required): Future getSharedLinkByIdWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/shared-links/{id}' + final apiPath = r'/shared-links/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -255,7 +265,7 @@ class SharedLinksApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -289,7 +299,7 @@ class SharedLinksApi { /// * [String] id (required): Future removeSharedLinkWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/shared-links/{id}' + final apiPath = r'/shared-links/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -303,7 +313,7 @@ class SharedLinksApi { return apiClient.invokeAPI( - path, + apiPath, 'DELETE', queryParams, postBody, @@ -333,7 +343,7 @@ class SharedLinksApi { /// * [String] key: Future removeSharedLinkAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto, { String? key, }) async { // ignore: prefer_const_declarations - final path = r'/shared-links/{id}/assets' + final apiPath = r'/shared-links/{id}/assets' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -351,7 +361,7 @@ class SharedLinksApi { return apiClient.invokeAPI( - path, + apiPath, 'DELETE', queryParams, postBody, @@ -394,7 +404,7 @@ class SharedLinksApi { /// * [SharedLinkEditDto] sharedLinkEditDto (required): Future updateSharedLinkWithHttpInfo(String id, SharedLinkEditDto sharedLinkEditDto,) async { // ignore: prefer_const_declarations - final path = r'/shared-links/{id}' + final apiPath = r'/shared-links/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -408,7 +418,7 @@ class SharedLinksApi { return apiClient.invokeAPI( - path, + apiPath, 'PATCH', queryParams, postBody, diff --git a/mobile/openapi/lib/api/stacks_api.dart b/mobile/openapi/lib/api/stacks_api.dart index aa1d9b3416..84f23ec55d 100644 --- a/mobile/openapi/lib/api/stacks_api.dart +++ b/mobile/openapi/lib/api/stacks_api.dart @@ -22,7 +22,7 @@ class StacksApi { /// * [StackCreateDto] stackCreateDto (required): Future createStackWithHttpInfo(StackCreateDto stackCreateDto,) async { // ignore: prefer_const_declarations - final path = r'/stacks'; + final apiPath = r'/stacks'; // ignore: prefer_final_locals Object? postBody = stackCreateDto; @@ -35,7 +35,7 @@ class StacksApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -69,7 +69,7 @@ class StacksApi { /// * [String] id (required): Future deleteStackWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/stacks/{id}' + final apiPath = r'/stacks/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -83,7 +83,7 @@ class StacksApi { return apiClient.invokeAPI( - path, + apiPath, 'DELETE', queryParams, postBody, @@ -109,7 +109,7 @@ class StacksApi { /// * [BulkIdsDto] bulkIdsDto (required): Future deleteStacksWithHttpInfo(BulkIdsDto bulkIdsDto,) async { // ignore: prefer_const_declarations - final path = r'/stacks'; + final apiPath = r'/stacks'; // ignore: prefer_final_locals Object? postBody = bulkIdsDto; @@ -122,7 +122,7 @@ class StacksApi { return apiClient.invokeAPI( - path, + apiPath, 'DELETE', queryParams, postBody, @@ -148,7 +148,7 @@ class StacksApi { /// * [String] id (required): Future getStackWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/stacks/{id}' + final apiPath = r'/stacks/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -162,7 +162,7 @@ class StacksApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -196,7 +196,7 @@ class StacksApi { /// * [String] primaryAssetId: Future searchStacksWithHttpInfo({ String? primaryAssetId, }) async { // ignore: prefer_const_declarations - final path = r'/stacks'; + final apiPath = r'/stacks'; // ignore: prefer_final_locals Object? postBody; @@ -213,7 +213,7 @@ class StacksApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -252,7 +252,7 @@ class StacksApi { /// * [StackUpdateDto] stackUpdateDto (required): Future updateStackWithHttpInfo(String id, StackUpdateDto stackUpdateDto,) async { // ignore: prefer_const_declarations - final path = r'/stacks/{id}' + final apiPath = r'/stacks/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -266,7 +266,7 @@ class StacksApi { return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, diff --git a/mobile/openapi/lib/api/sync_api.dart b/mobile/openapi/lib/api/sync_api.dart index f94eb88081..fe2876ddd8 100644 --- a/mobile/openapi/lib/api/sync_api.dart +++ b/mobile/openapi/lib/api/sync_api.dart @@ -16,13 +16,52 @@ class SyncApi { final ApiClient apiClient; + /// Performs an HTTP 'DELETE /sync/ack' operation and returns the [Response]. + /// Parameters: + /// + /// * [SyncAckDeleteDto] syncAckDeleteDto (required): + Future deleteSyncAckWithHttpInfo(SyncAckDeleteDto syncAckDeleteDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/sync/ack'; + + // ignore: prefer_final_locals + Object? postBody = syncAckDeleteDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [SyncAckDeleteDto] syncAckDeleteDto (required): + Future deleteSyncAck(SyncAckDeleteDto syncAckDeleteDto,) async { + final response = await deleteSyncAckWithHttpInfo(syncAckDeleteDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Performs an HTTP 'POST /sync/delta-sync' operation and returns the [Response]. /// Parameters: /// /// * [AssetDeltaSyncDto] assetDeltaSyncDto (required): Future getDeltaSyncWithHttpInfo(AssetDeltaSyncDto assetDeltaSyncDto,) async { // ignore: prefer_const_declarations - final path = r'/sync/delta-sync'; + final apiPath = r'/sync/delta-sync'; // ignore: prefer_final_locals Object? postBody = assetDeltaSyncDto; @@ -35,7 +74,7 @@ class SyncApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -69,7 +108,7 @@ class SyncApi { /// * [AssetFullSyncDto] assetFullSyncDto (required): Future getFullSyncForUserWithHttpInfo(AssetFullSyncDto assetFullSyncDto,) async { // ignore: prefer_const_declarations - final path = r'/sync/full-sync'; + final apiPath = r'/sync/full-sync'; // ignore: prefer_final_locals Object? postBody = assetFullSyncDto; @@ -82,7 +121,7 @@ class SyncApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -112,4 +151,126 @@ class SyncApi { } return null; } + + /// Performs an HTTP 'GET /sync/ack' operation and returns the [Response]. + Future getSyncAckWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/sync/ack'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future?> getSyncAck() async { + final response = await getSyncAckWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + + /// Performs an HTTP 'POST /sync/stream' operation and returns the [Response]. + /// Parameters: + /// + /// * [SyncStreamDto] syncStreamDto (required): + Future getSyncStreamWithHttpInfo(SyncStreamDto syncStreamDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/sync/stream'; + + // ignore: prefer_final_locals + Object? postBody = syncStreamDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [SyncStreamDto] syncStreamDto (required): + Future getSyncStream(SyncStreamDto syncStreamDto,) async { + final response = await getSyncStreamWithHttpInfo(syncStreamDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Performs an HTTP 'POST /sync/ack' operation and returns the [Response]. + /// Parameters: + /// + /// * [SyncAckSetDto] syncAckSetDto (required): + Future sendSyncAckWithHttpInfo(SyncAckSetDto syncAckSetDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/sync/ack'; + + // ignore: prefer_final_locals + Object? postBody = syncAckSetDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [SyncAckSetDto] syncAckSetDto (required): + Future sendSyncAck(SyncAckSetDto syncAckSetDto,) async { + final response = await sendSyncAckWithHttpInfo(syncAckSetDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } } diff --git a/mobile/openapi/lib/api/system_config_api.dart b/mobile/openapi/lib/api/system_config_api.dart index b63b2b70c4..a03b9d3e72 100644 --- a/mobile/openapi/lib/api/system_config_api.dart +++ b/mobile/openapi/lib/api/system_config_api.dart @@ -19,7 +19,7 @@ class SystemConfigApi { /// Performs an HTTP 'GET /system-config' operation and returns the [Response]. Future getConfigWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/system-config'; + final apiPath = r'/system-config'; // ignore: prefer_final_locals Object? postBody; @@ -32,7 +32,7 @@ class SystemConfigApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -60,7 +60,7 @@ class SystemConfigApi { /// Performs an HTTP 'GET /system-config/defaults' operation and returns the [Response]. Future getConfigDefaultsWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/system-config/defaults'; + final apiPath = r'/system-config/defaults'; // ignore: prefer_final_locals Object? postBody; @@ -73,7 +73,7 @@ class SystemConfigApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -101,7 +101,7 @@ class SystemConfigApi { /// Performs an HTTP 'GET /system-config/storage-template-options' operation and returns the [Response]. Future getStorageTemplateOptionsWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/system-config/storage-template-options'; + final apiPath = r'/system-config/storage-template-options'; // ignore: prefer_final_locals Object? postBody; @@ -114,7 +114,7 @@ class SystemConfigApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -145,7 +145,7 @@ class SystemConfigApi { /// * [SystemConfigDto] systemConfigDto (required): Future updateConfigWithHttpInfo(SystemConfigDto systemConfigDto,) async { // ignore: prefer_const_declarations - final path = r'/system-config'; + final apiPath = r'/system-config'; // ignore: prefer_final_locals Object? postBody = systemConfigDto; @@ -158,7 +158,7 @@ class SystemConfigApi { return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, diff --git a/mobile/openapi/lib/api/system_metadata_api.dart b/mobile/openapi/lib/api/system_metadata_api.dart index 822a54b14f..3bd8bddcac 100644 --- a/mobile/openapi/lib/api/system_metadata_api.dart +++ b/mobile/openapi/lib/api/system_metadata_api.dart @@ -19,7 +19,7 @@ class SystemMetadataApi { /// Performs an HTTP 'GET /system-metadata/admin-onboarding' operation and returns the [Response]. Future getAdminOnboardingWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/system-metadata/admin-onboarding'; + final apiPath = r'/system-metadata/admin-onboarding'; // ignore: prefer_final_locals Object? postBody; @@ -32,7 +32,7 @@ class SystemMetadataApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -60,7 +60,7 @@ class SystemMetadataApi { /// Performs an HTTP 'GET /system-metadata/reverse-geocoding-state' operation and returns the [Response]. Future getReverseGeocodingStateWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/system-metadata/reverse-geocoding-state'; + final apiPath = r'/system-metadata/reverse-geocoding-state'; // ignore: prefer_final_locals Object? postBody; @@ -73,7 +73,7 @@ class SystemMetadataApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -104,7 +104,7 @@ class SystemMetadataApi { /// * [AdminOnboardingUpdateDto] adminOnboardingUpdateDto (required): Future updateAdminOnboardingWithHttpInfo(AdminOnboardingUpdateDto adminOnboardingUpdateDto,) async { // ignore: prefer_const_declarations - final path = r'/system-metadata/admin-onboarding'; + final apiPath = r'/system-metadata/admin-onboarding'; // ignore: prefer_final_locals Object? postBody = adminOnboardingUpdateDto; @@ -117,7 +117,7 @@ class SystemMetadataApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, diff --git a/mobile/openapi/lib/api/tags_api.dart b/mobile/openapi/lib/api/tags_api.dart index 87c9001a3c..f6cfc8720b 100644 --- a/mobile/openapi/lib/api/tags_api.dart +++ b/mobile/openapi/lib/api/tags_api.dart @@ -22,7 +22,7 @@ class TagsApi { /// * [TagBulkAssetsDto] tagBulkAssetsDto (required): Future bulkTagAssetsWithHttpInfo(TagBulkAssetsDto tagBulkAssetsDto,) async { // ignore: prefer_const_declarations - final path = r'/tags/assets'; + final apiPath = r'/tags/assets'; // ignore: prefer_final_locals Object? postBody = tagBulkAssetsDto; @@ -35,7 +35,7 @@ class TagsApi { return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, @@ -69,7 +69,7 @@ class TagsApi { /// * [TagCreateDto] tagCreateDto (required): Future createTagWithHttpInfo(TagCreateDto tagCreateDto,) async { // ignore: prefer_const_declarations - final path = r'/tags'; + final apiPath = r'/tags'; // ignore: prefer_final_locals Object? postBody = tagCreateDto; @@ -82,7 +82,7 @@ class TagsApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -116,7 +116,7 @@ class TagsApi { /// * [String] id (required): Future deleteTagWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/tags/{id}' + final apiPath = r'/tags/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -130,7 +130,7 @@ class TagsApi { return apiClient.invokeAPI( - path, + apiPath, 'DELETE', queryParams, postBody, @@ -153,7 +153,7 @@ class TagsApi { /// Performs an HTTP 'GET /tags' operation and returns the [Response]. Future getAllTagsWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/tags'; + final apiPath = r'/tags'; // ignore: prefer_final_locals Object? postBody; @@ -166,7 +166,7 @@ class TagsApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -200,7 +200,7 @@ class TagsApi { /// * [String] id (required): Future getTagByIdWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/tags/{id}' + final apiPath = r'/tags/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -214,7 +214,7 @@ class TagsApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -250,7 +250,7 @@ class TagsApi { /// * [BulkIdsDto] bulkIdsDto (required): Future tagAssetsWithHttpInfo(String id, BulkIdsDto bulkIdsDto,) async { // ignore: prefer_const_declarations - final path = r'/tags/{id}/assets' + final apiPath = r'/tags/{id}/assets' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -264,7 +264,7 @@ class TagsApi { return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, @@ -305,7 +305,7 @@ class TagsApi { /// * [BulkIdsDto] bulkIdsDto (required): Future untagAssetsWithHttpInfo(String id, BulkIdsDto bulkIdsDto,) async { // ignore: prefer_const_declarations - final path = r'/tags/{id}/assets' + final apiPath = r'/tags/{id}/assets' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -319,7 +319,7 @@ class TagsApi { return apiClient.invokeAPI( - path, + apiPath, 'DELETE', queryParams, postBody, @@ -360,7 +360,7 @@ class TagsApi { /// * [TagUpdateDto] tagUpdateDto (required): Future updateTagWithHttpInfo(String id, TagUpdateDto tagUpdateDto,) async { // ignore: prefer_const_declarations - final path = r'/tags/{id}' + final apiPath = r'/tags/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -374,7 +374,7 @@ class TagsApi { return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, @@ -410,7 +410,7 @@ class TagsApi { /// * [TagUpsertDto] tagUpsertDto (required): Future upsertTagsWithHttpInfo(TagUpsertDto tagUpsertDto,) async { // ignore: prefer_const_declarations - final path = r'/tags'; + final apiPath = r'/tags'; // ignore: prefer_final_locals Object? postBody = tagUpsertDto; @@ -423,7 +423,7 @@ class TagsApi { return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, diff --git a/mobile/openapi/lib/api/timeline_api.dart b/mobile/openapi/lib/api/timeline_api.dart index 8c94e09bf5..7ea7189b00 100644 --- a/mobile/openapi/lib/api/timeline_api.dart +++ b/mobile/openapi/lib/api/timeline_api.dart @@ -46,7 +46,7 @@ class TimelineApi { /// * [bool] withStacked: Future getTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { // ignore: prefer_const_declarations - final path = r'/timeline/bucket'; + final apiPath = r'/timeline/bucket'; // ignore: prefer_final_locals Object? postBody; @@ -95,7 +95,7 @@ class TimelineApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -178,7 +178,7 @@ class TimelineApi { /// * [bool] withStacked: Future getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { // ignore: prefer_const_declarations - final path = r'/timeline/buckets'; + final apiPath = r'/timeline/buckets'; // ignore: prefer_final_locals Object? postBody; @@ -226,7 +226,7 @@ class TimelineApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, diff --git a/mobile/openapi/lib/api/trash_api.dart b/mobile/openapi/lib/api/trash_api.dart index 8f8c6ffb3a..982dbcbeda 100644 --- a/mobile/openapi/lib/api/trash_api.dart +++ b/mobile/openapi/lib/api/trash_api.dart @@ -19,7 +19,7 @@ class TrashApi { /// Performs an HTTP 'POST /trash/empty' operation and returns the [Response]. Future emptyTrashWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/trash/empty'; + final apiPath = r'/trash/empty'; // ignore: prefer_final_locals Object? postBody; @@ -32,7 +32,7 @@ class TrashApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -63,7 +63,7 @@ class TrashApi { /// * [BulkIdsDto] bulkIdsDto (required): Future restoreAssetsWithHttpInfo(BulkIdsDto bulkIdsDto,) async { // ignore: prefer_const_declarations - final path = r'/trash/restore/assets'; + final apiPath = r'/trash/restore/assets'; // ignore: prefer_final_locals Object? postBody = bulkIdsDto; @@ -76,7 +76,7 @@ class TrashApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -107,7 +107,7 @@ class TrashApi { /// Performs an HTTP 'POST /trash/restore' operation and returns the [Response]. Future restoreTrashWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/trash/restore'; + final apiPath = r'/trash/restore'; // ignore: prefer_final_locals Object? postBody; @@ -120,7 +120,7 @@ class TrashApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, diff --git a/mobile/openapi/lib/api/users_admin_api.dart b/mobile/openapi/lib/api/users_admin_api.dart index a074645e08..b4508d7dcd 100644 --- a/mobile/openapi/lib/api/users_admin_api.dart +++ b/mobile/openapi/lib/api/users_admin_api.dart @@ -22,7 +22,7 @@ class UsersAdminApi { /// * [UserAdminCreateDto] userAdminCreateDto (required): Future createUserAdminWithHttpInfo(UserAdminCreateDto userAdminCreateDto,) async { // ignore: prefer_const_declarations - final path = r'/admin/users'; + final apiPath = r'/admin/users'; // ignore: prefer_final_locals Object? postBody = userAdminCreateDto; @@ -35,7 +35,7 @@ class UsersAdminApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -71,7 +71,7 @@ class UsersAdminApi { /// * [UserAdminDeleteDto] userAdminDeleteDto (required): Future deleteUserAdminWithHttpInfo(String id, UserAdminDeleteDto userAdminDeleteDto,) async { // ignore: prefer_const_declarations - final path = r'/admin/users/{id}' + final apiPath = r'/admin/users/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -85,7 +85,7 @@ class UsersAdminApi { return apiClient.invokeAPI( - path, + apiPath, 'DELETE', queryParams, postBody, @@ -121,7 +121,7 @@ class UsersAdminApi { /// * [String] id (required): Future getUserAdminWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/admin/users/{id}' + final apiPath = r'/admin/users/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -135,7 +135,7 @@ class UsersAdminApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -169,7 +169,7 @@ class UsersAdminApi { /// * [String] id (required): Future getUserPreferencesAdminWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/admin/users/{id}/preferences' + final apiPath = r'/admin/users/{id}/preferences' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -183,7 +183,7 @@ class UsersAdminApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -217,7 +217,7 @@ class UsersAdminApi { /// * [String] id (required): Future restoreUserAdminWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/admin/users/{id}/restore' + final apiPath = r'/admin/users/{id}/restore' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -231,7 +231,7 @@ class UsersAdminApi { return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -265,7 +265,7 @@ class UsersAdminApi { /// * [bool] withDeleted: Future searchUsersAdminWithHttpInfo({ bool? withDeleted, }) async { // ignore: prefer_const_declarations - final path = r'/admin/users'; + final apiPath = r'/admin/users'; // ignore: prefer_final_locals Object? postBody; @@ -282,7 +282,7 @@ class UsersAdminApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -321,7 +321,7 @@ class UsersAdminApi { /// * [UserAdminUpdateDto] userAdminUpdateDto (required): Future updateUserAdminWithHttpInfo(String id, UserAdminUpdateDto userAdminUpdateDto,) async { // ignore: prefer_const_declarations - final path = r'/admin/users/{id}' + final apiPath = r'/admin/users/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -335,7 +335,7 @@ class UsersAdminApi { return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, @@ -373,7 +373,7 @@ class UsersAdminApi { /// * [UserPreferencesUpdateDto] userPreferencesUpdateDto (required): Future updateUserPreferencesAdminWithHttpInfo(String id, UserPreferencesUpdateDto userPreferencesUpdateDto,) async { // ignore: prefer_const_declarations - final path = r'/admin/users/{id}/preferences' + final apiPath = r'/admin/users/{id}/preferences' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -387,7 +387,7 @@ class UsersAdminApi { return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, diff --git a/mobile/openapi/lib/api/users_api.dart b/mobile/openapi/lib/api/users_api.dart index b2b9fa8826..a48ec54cfe 100644 --- a/mobile/openapi/lib/api/users_api.dart +++ b/mobile/openapi/lib/api/users_api.dart @@ -22,7 +22,7 @@ class UsersApi { /// * [MultipartFile] file (required): Future createProfileImageWithHttpInfo(MultipartFile file,) async { // ignore: prefer_const_declarations - final path = r'/users/profile-image'; + final apiPath = r'/users/profile-image'; // ignore: prefer_final_locals Object? postBody; @@ -34,7 +34,7 @@ class UsersApi { const contentTypes = ['multipart/form-data']; bool hasFields = false; - final mp = MultipartRequest('POST', Uri.parse(path)); + final mp = MultipartRequest('POST', Uri.parse(apiPath)); if (file != null) { hasFields = true; mp.fields[r'file'] = file.field; @@ -45,7 +45,7 @@ class UsersApi { } return apiClient.invokeAPI( - path, + apiPath, 'POST', queryParams, postBody, @@ -76,7 +76,7 @@ class UsersApi { /// Performs an HTTP 'DELETE /users/profile-image' operation and returns the [Response]. Future deleteProfileImageWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/users/profile-image'; + final apiPath = r'/users/profile-image'; // ignore: prefer_final_locals Object? postBody; @@ -89,7 +89,7 @@ class UsersApi { return apiClient.invokeAPI( - path, + apiPath, 'DELETE', queryParams, postBody, @@ -109,7 +109,7 @@ class UsersApi { /// Performs an HTTP 'DELETE /users/me/license' operation and returns the [Response]. Future deleteUserLicenseWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/users/me/license'; + final apiPath = r'/users/me/license'; // ignore: prefer_final_locals Object? postBody; @@ -122,7 +122,7 @@ class UsersApi { return apiClient.invokeAPI( - path, + apiPath, 'DELETE', queryParams, postBody, @@ -142,7 +142,7 @@ class UsersApi { /// Performs an HTTP 'GET /users/me/preferences' operation and returns the [Response]. Future getMyPreferencesWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/users/me/preferences'; + final apiPath = r'/users/me/preferences'; // ignore: prefer_final_locals Object? postBody; @@ -155,7 +155,7 @@ class UsersApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -183,7 +183,7 @@ class UsersApi { /// Performs an HTTP 'GET /users/me' operation and returns the [Response]. Future getMyUserWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/users/me'; + final apiPath = r'/users/me'; // ignore: prefer_final_locals Object? postBody; @@ -196,7 +196,7 @@ class UsersApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -227,7 +227,7 @@ class UsersApi { /// * [String] id (required): Future getProfileImageWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/users/{id}/profile-image' + final apiPath = r'/users/{id}/profile-image' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -241,7 +241,7 @@ class UsersApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -275,7 +275,7 @@ class UsersApi { /// * [String] id (required): Future getUserWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/users/{id}' + final apiPath = r'/users/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -289,7 +289,7 @@ class UsersApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -320,7 +320,7 @@ class UsersApi { /// Performs an HTTP 'GET /users/me/license' operation and returns the [Response]. Future getUserLicenseWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/users/me/license'; + final apiPath = r'/users/me/license'; // ignore: prefer_final_locals Object? postBody; @@ -333,7 +333,7 @@ class UsersApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -361,7 +361,7 @@ class UsersApi { /// Performs an HTTP 'GET /users' operation and returns the [Response]. Future searchUsersWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/users'; + final apiPath = r'/users'; // ignore: prefer_final_locals Object? postBody; @@ -374,7 +374,7 @@ class UsersApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -408,7 +408,7 @@ class UsersApi { /// * [LicenseKeyDto] licenseKeyDto (required): Future setUserLicenseWithHttpInfo(LicenseKeyDto licenseKeyDto,) async { // ignore: prefer_const_declarations - final path = r'/users/me/license'; + final apiPath = r'/users/me/license'; // ignore: prefer_final_locals Object? postBody = licenseKeyDto; @@ -421,7 +421,7 @@ class UsersApi { return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, @@ -455,7 +455,7 @@ class UsersApi { /// * [UserPreferencesUpdateDto] userPreferencesUpdateDto (required): Future updateMyPreferencesWithHttpInfo(UserPreferencesUpdateDto userPreferencesUpdateDto,) async { // ignore: prefer_const_declarations - final path = r'/users/me/preferences'; + final apiPath = r'/users/me/preferences'; // ignore: prefer_final_locals Object? postBody = userPreferencesUpdateDto; @@ -468,7 +468,7 @@ class UsersApi { return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, @@ -502,7 +502,7 @@ class UsersApi { /// * [UserUpdateMeDto] userUpdateMeDto (required): Future updateMyUserWithHttpInfo(UserUpdateMeDto userUpdateMeDto,) async { // ignore: prefer_const_declarations - final path = r'/users/me'; + final apiPath = r'/users/me'; // ignore: prefer_final_locals Object? postBody = userUpdateMeDto; @@ -515,7 +515,7 @@ class UsersApi { return apiClient.invokeAPI( - path, + apiPath, 'PUT', queryParams, postBody, diff --git a/mobile/openapi/lib/api/view_api.dart b/mobile/openapi/lib/api/view_api.dart index f4489f2d1a..1fcaec759c 100644 --- a/mobile/openapi/lib/api/view_api.dart +++ b/mobile/openapi/lib/api/view_api.dart @@ -22,7 +22,7 @@ class ViewApi { /// * [String] path (required): Future getAssetsByOriginalPathWithHttpInfo(String path,) async { // ignore: prefer_const_declarations - final path = r'/view/folder'; + final apiPath = r'/view/folder'; // ignore: prefer_final_locals Object? postBody; @@ -37,7 +37,7 @@ class ViewApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, @@ -71,7 +71,7 @@ class ViewApi { /// Performs an HTTP 'GET /view/folder/unique-paths' operation and returns the [Response]. Future getUniqueOriginalPathsWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/view/folder/unique-paths'; + final apiPath = r'/view/folder/unique-paths'; // ignore: prefer_final_locals Object? postBody; @@ -84,7 +84,7 @@ class ViewApi { return apiClient.invokeAPI( - path, + apiPath, 'GET', queryParams, postBody, diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index a6f8d551da..783e5e375e 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -230,6 +230,10 @@ class ApiClient { return AssetDeltaSyncDto.fromJson(value); case 'AssetDeltaSyncResponseDto': return AssetDeltaSyncResponseDto.fromJson(value); + case 'AssetFaceCreateDto': + return AssetFaceCreateDto.fromJson(value); + case 'AssetFaceDeleteDto': + return AssetFaceDeleteDto.fromJson(value); case 'AssetFaceResponseDto': return AssetFaceResponseDto.fromJson(value); case 'AssetFaceUpdateDto': @@ -266,8 +270,6 @@ class ApiClient { return AssetTypeEnumTypeTransformer().decode(value); case 'AudioCodec': return AudioCodecTypeTransformer().decode(value); - case 'AuditDeletesResponseDto': - return AuditDeletesResponseDto.fromJson(value); case 'AvatarResponse': return AvatarResponse.fromJson(value); case 'AvatarUpdate': @@ -314,8 +316,6 @@ class ApiClient { return EmailNotificationsResponse.fromJson(value); case 'EmailNotificationsUpdate': return EmailNotificationsUpdate.fromJson(value); - case 'EntityType': - return EntityTypeTypeTransformer().decode(value); case 'ExifResponseDto': return ExifResponseDto.fromJson(value); case 'FaceDto': @@ -496,6 +496,10 @@ class ApiClient { return SharedLinkResponseDto.fromJson(value); case 'SharedLinkType': return SharedLinkTypeTypeTransformer().decode(value); + case 'SharedLinksResponse': + return SharedLinksResponse.fromJson(value); + case 'SharedLinksUpdate': + return SharedLinksUpdate.fromJson(value); case 'SignUpDto': return SignUpDto.fromJson(value); case 'SmartSearchDto': @@ -508,6 +512,32 @@ class ApiClient { return StackResponseDto.fromJson(value); case 'StackUpdateDto': return StackUpdateDto.fromJson(value); + case 'SyncAckDeleteDto': + return SyncAckDeleteDto.fromJson(value); + case 'SyncAckDto': + return SyncAckDto.fromJson(value); + case 'SyncAckSetDto': + return SyncAckSetDto.fromJson(value); + case 'SyncAssetDeleteV1': + return SyncAssetDeleteV1.fromJson(value); + case 'SyncAssetExifV1': + return SyncAssetExifV1.fromJson(value); + case 'SyncAssetV1': + return SyncAssetV1.fromJson(value); + case 'SyncEntityType': + return SyncEntityTypeTypeTransformer().decode(value); + case 'SyncPartnerDeleteV1': + return SyncPartnerDeleteV1.fromJson(value); + case 'SyncPartnerV1': + return SyncPartnerV1.fromJson(value); + case 'SyncRequestType': + return SyncRequestTypeTypeTransformer().decode(value); + case 'SyncStreamDto': + return SyncStreamDto.fromJson(value); + case 'SyncUserDeleteV1': + return SyncUserDeleteV1.fromJson(value); + case 'SyncUserV1': + return SyncUserV1.fromJson(value); case 'SystemConfigBackupsDto': return SystemConfigBackupsDto.fromJson(value); case 'SystemConfigDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index b7c6ad5e01..1ebf8314ad 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -82,9 +82,6 @@ String parameterToString(dynamic value) { if (value is Colorspace) { return ColorspaceTypeTransformer().encode(value).toString(); } - if (value is EntityType) { - return EntityTypeTypeTransformer().encode(value).toString(); - } if (value is ImageFormat) { return ImageFormatTypeTransformer().encode(value).toString(); } @@ -130,6 +127,12 @@ String parameterToString(dynamic value) { if (value is SourceType) { return SourceTypeTypeTransformer().encode(value).toString(); } + if (value is SyncEntityType) { + return SyncEntityTypeTypeTransformer().encode(value).toString(); + } + if (value is SyncRequestType) { + return SyncRequestTypeTypeTransformer().encode(value).toString(); + } if (value is TimeBucketSize) { return TimeBucketSizeTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index da23d2f09d..0b5a2c30d9 100644 --- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart @@ -67,7 +67,7 @@ class AssetBulkUpdateDto { /// num? longitude; - /// Minimum value: 0 + /// Minimum value: -1 /// Maximum value: 5 /// /// Please note: This property should have been non-nullable! Since the specification file diff --git a/mobile/openapi/lib/model/asset_face_create_dto.dart b/mobile/openapi/lib/model/asset_face_create_dto.dart new file mode 100644 index 0000000000..29e8244a96 --- /dev/null +++ b/mobile/openapi/lib/model/asset_face_create_dto.dart @@ -0,0 +1,155 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetFaceCreateDto { + /// Returns a new [AssetFaceCreateDto] instance. + AssetFaceCreateDto({ + required this.assetId, + required this.height, + required this.imageHeight, + required this.imageWidth, + required this.personId, + required this.width, + required this.x, + required this.y, + }); + + String assetId; + + int height; + + int imageHeight; + + int imageWidth; + + String personId; + + int width; + + int x; + + int y; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetFaceCreateDto && + other.assetId == assetId && + other.height == height && + other.imageHeight == imageHeight && + other.imageWidth == imageWidth && + other.personId == personId && + other.width == width && + other.x == x && + other.y == y; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode) + + (height.hashCode) + + (imageHeight.hashCode) + + (imageWidth.hashCode) + + (personId.hashCode) + + (width.hashCode) + + (x.hashCode) + + (y.hashCode); + + @override + String toString() => 'AssetFaceCreateDto[assetId=$assetId, height=$height, imageHeight=$imageHeight, imageWidth=$imageWidth, personId=$personId, width=$width, x=$x, y=$y]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + json[r'height'] = this.height; + json[r'imageHeight'] = this.imageHeight; + json[r'imageWidth'] = this.imageWidth; + json[r'personId'] = this.personId; + json[r'width'] = this.width; + json[r'x'] = this.x; + json[r'y'] = this.y; + return json; + } + + /// Returns a new [AssetFaceCreateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetFaceCreateDto? fromJson(dynamic value) { + upgradeDto(value, "AssetFaceCreateDto"); + if (value is Map) { + final json = value.cast(); + + return AssetFaceCreateDto( + assetId: mapValueOfType(json, r'assetId')!, + height: mapValueOfType(json, r'height')!, + imageHeight: mapValueOfType(json, r'imageHeight')!, + imageWidth: mapValueOfType(json, r'imageWidth')!, + personId: mapValueOfType(json, r'personId')!, + width: mapValueOfType(json, r'width')!, + x: mapValueOfType(json, r'x')!, + y: mapValueOfType(json, r'y')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetFaceCreateDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetFaceCreateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetFaceCreateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetFaceCreateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'height', + 'imageHeight', + 'imageWidth', + 'personId', + 'width', + 'x', + 'y', + }; +} + diff --git a/mobile/openapi/lib/model/asset_face_delete_dto.dart b/mobile/openapi/lib/model/asset_face_delete_dto.dart new file mode 100644 index 0000000000..2e53b0699c --- /dev/null +++ b/mobile/openapi/lib/model/asset_face_delete_dto.dart @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetFaceDeleteDto { + /// Returns a new [AssetFaceDeleteDto] instance. + AssetFaceDeleteDto({ + required this.force, + }); + + bool force; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetFaceDeleteDto && + other.force == force; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (force.hashCode); + + @override + String toString() => 'AssetFaceDeleteDto[force=$force]'; + + Map toJson() { + final json = {}; + json[r'force'] = this.force; + return json; + } + + /// Returns a new [AssetFaceDeleteDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetFaceDeleteDto? fromJson(dynamic value) { + upgradeDto(value, "AssetFaceDeleteDto"); + if (value is Map) { + final json = value.cast(); + + return AssetFaceDeleteDto( + force: mapValueOfType(json, r'force')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetFaceDeleteDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetFaceDeleteDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetFaceDeleteDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetFaceDeleteDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'force', + }; +} + diff --git a/mobile/openapi/lib/model/audit_deletes_response_dto.dart b/mobile/openapi/lib/model/audit_deletes_response_dto.dart deleted file mode 100644 index 6b1df74eb4..0000000000 --- a/mobile/openapi/lib/model/audit_deletes_response_dto.dart +++ /dev/null @@ -1,109 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - -class AuditDeletesResponseDto { - /// Returns a new [AuditDeletesResponseDto] instance. - AuditDeletesResponseDto({ - this.ids = const [], - required this.needsFullSync, - }); - - List ids; - - bool needsFullSync; - - @override - bool operator ==(Object other) => identical(this, other) || other is AuditDeletesResponseDto && - _deepEquality.equals(other.ids, ids) && - other.needsFullSync == needsFullSync; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (ids.hashCode) + - (needsFullSync.hashCode); - - @override - String toString() => 'AuditDeletesResponseDto[ids=$ids, needsFullSync=$needsFullSync]'; - - Map toJson() { - final json = {}; - json[r'ids'] = this.ids; - json[r'needsFullSync'] = this.needsFullSync; - return json; - } - - /// Returns a new [AuditDeletesResponseDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static AuditDeletesResponseDto? fromJson(dynamic value) { - upgradeDto(value, "AuditDeletesResponseDto"); - if (value is Map) { - final json = value.cast(); - - return AuditDeletesResponseDto( - ids: json[r'ids'] is Iterable - ? (json[r'ids'] as Iterable).cast().toList(growable: false) - : const [], - needsFullSync: mapValueOfType(json, r'needsFullSync')!, - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = AuditDeletesResponseDto.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = AuditDeletesResponseDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of AuditDeletesResponseDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = AuditDeletesResponseDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'ids', - 'needsFullSync', - }; -} - diff --git a/mobile/openapi/lib/model/entity_type.dart b/mobile/openapi/lib/model/entity_type.dart deleted file mode 100644 index 93a0d0d3cc..0000000000 --- a/mobile/openapi/lib/model/entity_type.dart +++ /dev/null @@ -1,85 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - - -class EntityType { - /// Instantiate a new enum with the provided [value]. - const EntityType._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - static const ASSET = EntityType._(r'ASSET'); - static const ALBUM = EntityType._(r'ALBUM'); - - /// List of all possible values in this [enum][EntityType]. - static const values = [ - ASSET, - ALBUM, - ]; - - static EntityType? fromJson(dynamic value) => EntityTypeTypeTransformer().decode(value); - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = EntityType.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } -} - -/// Transformation class that can [encode] an instance of [EntityType] to String, -/// and [decode] dynamic data back to [EntityType]. -class EntityTypeTypeTransformer { - factory EntityTypeTypeTransformer() => _instance ??= const EntityTypeTypeTransformer._(); - - const EntityTypeTypeTransformer._(); - - String encode(EntityType data) => data.value; - - /// Decodes a [dynamic value][data] to a EntityType. - /// - /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, - /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] - /// cannot be decoded successfully, then an [UnimplementedError] is thrown. - /// - /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, - /// and users are still using an old app with the old code. - EntityType? decode(dynamic data, {bool allowNull = true}) { - if (data != null) { - switch (data) { - case r'ASSET': return EntityType.ASSET; - case r'ALBUM': return EntityType.ALBUM; - default: - if (!allowNull) { - throw ArgumentError('Unknown enum value to decode: $data'); - } - } - } - return null; - } - - /// Singleton [EntityTypeTypeTransformer] instance. - static EntityTypeTypeTransformer? _instance; -} - diff --git a/mobile/openapi/lib/model/manual_job_name.dart b/mobile/openapi/lib/model/manual_job_name.dart index 7e8d9d51b2..311215ad9e 100644 --- a/mobile/openapi/lib/model/manual_job_name.dart +++ b/mobile/openapi/lib/model/manual_job_name.dart @@ -26,12 +26,18 @@ class ManualJobName { static const personCleanup = ManualJobName._(r'person-cleanup'); static const tagCleanup = ManualJobName._(r'tag-cleanup'); static const userCleanup = ManualJobName._(r'user-cleanup'); + static const memoryCleanup = ManualJobName._(r'memory-cleanup'); + static const memoryCreate = ManualJobName._(r'memory-create'); + static const backupDatabase = ManualJobName._(r'backup-database'); /// List of all possible values in this [enum][ManualJobName]. static const values = [ personCleanup, tagCleanup, userCleanup, + memoryCleanup, + memoryCreate, + backupDatabase, ]; static ManualJobName? fromJson(dynamic value) => ManualJobNameTypeTransformer().decode(value); @@ -73,6 +79,9 @@ class ManualJobNameTypeTransformer { case r'person-cleanup': return ManualJobName.personCleanup; case r'tag-cleanup': return ManualJobName.tagCleanup; case r'user-cleanup': return ManualJobName.userCleanup; + case r'memory-cleanup': return ManualJobName.memoryCleanup; + case r'memory-create': return ManualJobName.memoryCreate; + case r'backup-database': return ManualJobName.backupDatabase; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/mobile/openapi/lib/model/memory_response_dto.dart b/mobile/openapi/lib/model/memory_response_dto.dart index 652c993536..7d50259e24 100644 --- a/mobile/openapi/lib/model/memory_response_dto.dart +++ b/mobile/openapi/lib/model/memory_response_dto.dart @@ -17,11 +17,13 @@ class MemoryResponseDto { required this.createdAt, required this.data, this.deletedAt, + this.hideAt, required this.id, required this.isSaved, required this.memoryAt, required this.ownerId, this.seenAt, + this.showAt, required this.type, required this.updatedAt, }); @@ -40,6 +42,14 @@ class MemoryResponseDto { /// DateTime? deletedAt; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? hideAt; + String id; bool isSaved; @@ -56,6 +66,14 @@ class MemoryResponseDto { /// DateTime? seenAt; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? showAt; + MemoryType type; DateTime updatedAt; @@ -66,11 +84,13 @@ class MemoryResponseDto { other.createdAt == createdAt && other.data == data && other.deletedAt == deletedAt && + other.hideAt == hideAt && other.id == id && other.isSaved == isSaved && other.memoryAt == memoryAt && other.ownerId == ownerId && other.seenAt == seenAt && + other.showAt == showAt && other.type == type && other.updatedAt == updatedAt; @@ -81,16 +101,18 @@ class MemoryResponseDto { (createdAt.hashCode) + (data.hashCode) + (deletedAt == null ? 0 : deletedAt!.hashCode) + + (hideAt == null ? 0 : hideAt!.hashCode) + (id.hashCode) + (isSaved.hashCode) + (memoryAt.hashCode) + (ownerId.hashCode) + (seenAt == null ? 0 : seenAt!.hashCode) + + (showAt == null ? 0 : showAt!.hashCode) + (type.hashCode) + (updatedAt.hashCode); @override - String toString() => 'MemoryResponseDto[assets=$assets, createdAt=$createdAt, data=$data, deletedAt=$deletedAt, id=$id, isSaved=$isSaved, memoryAt=$memoryAt, ownerId=$ownerId, seenAt=$seenAt, type=$type, updatedAt=$updatedAt]'; + String toString() => 'MemoryResponseDto[assets=$assets, createdAt=$createdAt, data=$data, deletedAt=$deletedAt, hideAt=$hideAt, id=$id, isSaved=$isSaved, memoryAt=$memoryAt, ownerId=$ownerId, seenAt=$seenAt, showAt=$showAt, type=$type, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -101,6 +123,11 @@ class MemoryResponseDto { json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String(); } else { // json[r'deletedAt'] = null; + } + if (this.hideAt != null) { + json[r'hideAt'] = this.hideAt!.toUtc().toIso8601String(); + } else { + // json[r'hideAt'] = null; } json[r'id'] = this.id; json[r'isSaved'] = this.isSaved; @@ -110,6 +137,11 @@ class MemoryResponseDto { json[r'seenAt'] = this.seenAt!.toUtc().toIso8601String(); } else { // json[r'seenAt'] = null; + } + if (this.showAt != null) { + json[r'showAt'] = this.showAt!.toUtc().toIso8601String(); + } else { + // json[r'showAt'] = null; } json[r'type'] = this.type; json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); @@ -129,11 +161,13 @@ class MemoryResponseDto { createdAt: mapDateTime(json, r'createdAt', r'')!, data: OnThisDayDto.fromJson(json[r'data'])!, deletedAt: mapDateTime(json, r'deletedAt', r''), + hideAt: mapDateTime(json, r'hideAt', r''), id: mapValueOfType(json, r'id')!, isSaved: mapValueOfType(json, r'isSaved')!, memoryAt: mapDateTime(json, r'memoryAt', r'')!, ownerId: mapValueOfType(json, r'ownerId')!, seenAt: mapDateTime(json, r'seenAt', r''), + showAt: mapDateTime(json, r'showAt', r''), type: MemoryType.fromJson(json[r'type'])!, updatedAt: mapDateTime(json, r'updatedAt', r'')!, ); diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index 654883b38a..3fb003d164 100644 --- a/mobile/openapi/lib/model/metadata_search_dto.dart +++ b/mobile/openapi/lib/model/metadata_search_dto.dart @@ -18,6 +18,7 @@ class MetadataSearchDto { this.country, this.createdAfter, this.createdBefore, + this.description, this.deviceAssetId, this.deviceId, this.encodedVideoPath, @@ -39,8 +40,10 @@ class MetadataSearchDto { this.page, this.personIds = const [], this.previewPath, + this.rating, this.size, this.state, + this.tagIds = const [], this.takenAfter, this.takenBefore, this.thumbnailPath, @@ -84,6 +87,14 @@ class MetadataSearchDto { /// DateTime? createdBefore; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? description; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -223,6 +234,16 @@ class MetadataSearchDto { /// String? previewPath; + /// Minimum value: -1 + /// Maximum value: 5 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + num? rating; + /// Minimum value: 1 /// Maximum value: 1000 /// @@ -235,6 +256,8 @@ class MetadataSearchDto { String? state; + List tagIds; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -340,6 +363,7 @@ class MetadataSearchDto { other.country == country && other.createdAfter == createdAfter && other.createdBefore == createdBefore && + other.description == description && other.deviceAssetId == deviceAssetId && other.deviceId == deviceId && other.encodedVideoPath == encodedVideoPath && @@ -361,8 +385,10 @@ class MetadataSearchDto { other.page == page && _deepEquality.equals(other.personIds, personIds) && other.previewPath == previewPath && + other.rating == rating && other.size == size && other.state == state && + _deepEquality.equals(other.tagIds, tagIds) && other.takenAfter == takenAfter && other.takenBefore == takenBefore && other.thumbnailPath == thumbnailPath && @@ -385,6 +411,7 @@ class MetadataSearchDto { (country == null ? 0 : country!.hashCode) + (createdAfter == null ? 0 : createdAfter!.hashCode) + (createdBefore == null ? 0 : createdBefore!.hashCode) + + (description == null ? 0 : description!.hashCode) + (deviceAssetId == null ? 0 : deviceAssetId!.hashCode) + (deviceId == null ? 0 : deviceId!.hashCode) + (encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) + @@ -406,8 +433,10 @@ class MetadataSearchDto { (page == null ? 0 : page!.hashCode) + (personIds.hashCode) + (previewPath == null ? 0 : previewPath!.hashCode) + + (rating == null ? 0 : rating!.hashCode) + (size == null ? 0 : size!.hashCode) + (state == null ? 0 : state!.hashCode) + + (tagIds.hashCode) + (takenAfter == null ? 0 : takenAfter!.hashCode) + (takenBefore == null ? 0 : takenBefore!.hashCode) + (thumbnailPath == null ? 0 : thumbnailPath!.hashCode) + @@ -423,7 +452,7 @@ class MetadataSearchDto { (withStacked == null ? 0 : withStacked!.hashCode); @override - String toString() => 'MetadataSearchDto[checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; + String toString() => 'MetadataSearchDto[checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, description=$description, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; Map toJson() { final json = {}; @@ -452,6 +481,11 @@ class MetadataSearchDto { } else { // json[r'createdBefore'] = null; } + if (this.description != null) { + json[r'description'] = this.description; + } else { + // json[r'description'] = null; + } if (this.deviceAssetId != null) { json[r'deviceAssetId'] = this.deviceAssetId; } else { @@ -549,6 +583,11 @@ class MetadataSearchDto { } else { // json[r'previewPath'] = null; } + if (this.rating != null) { + json[r'rating'] = this.rating; + } else { + // json[r'rating'] = null; + } if (this.size != null) { json[r'size'] = this.size; } else { @@ -559,6 +598,7 @@ class MetadataSearchDto { } else { // json[r'state'] = null; } + json[r'tagIds'] = this.tagIds; if (this.takenAfter != null) { json[r'takenAfter'] = this.takenAfter!.toUtc().toIso8601String(); } else { @@ -637,6 +677,7 @@ class MetadataSearchDto { country: mapValueOfType(json, r'country'), createdAfter: mapDateTime(json, r'createdAfter', r''), createdBefore: mapDateTime(json, r'createdBefore', r''), + description: mapValueOfType(json, r'description'), deviceAssetId: mapValueOfType(json, r'deviceAssetId'), deviceId: mapValueOfType(json, r'deviceId'), encodedVideoPath: mapValueOfType(json, r'encodedVideoPath'), @@ -660,8 +701,12 @@ class MetadataSearchDto { ? (json[r'personIds'] as Iterable).cast().toList(growable: false) : const [], previewPath: mapValueOfType(json, r'previewPath'), + rating: num.parse('${json[r'rating']}'), size: num.parse('${json[r'size']}'), state: mapValueOfType(json, r'state'), + tagIds: json[r'tagIds'] is Iterable + ? (json[r'tagIds'] as Iterable).cast().toList(growable: false) + : const [], takenAfter: mapDateTime(json, r'takenAfter', r''), takenBefore: mapDateTime(json, r'takenBefore', r''), thumbnailPath: mapValueOfType(json, r'thumbnailPath'), diff --git a/mobile/openapi/lib/model/people_update_item.dart b/mobile/openapi/lib/model/people_update_item.dart index 042e4fa36f..ce324b859e 100644 --- a/mobile/openapi/lib/model/people_update_item.dart +++ b/mobile/openapi/lib/model/people_update_item.dart @@ -14,8 +14,10 @@ class PeopleUpdateItem { /// Returns a new [PeopleUpdateItem] instance. PeopleUpdateItem({ this.birthDate, + this.color, this.featureFaceAssetId, required this.id, + this.isFavorite, this.isHidden, this.name, }); @@ -23,6 +25,8 @@ class PeopleUpdateItem { /// Person date of birth. Note: the mobile app cannot currently set the birth date to null. DateTime? birthDate; + String? color; + /// Asset is used to get the feature face thumbnail. /// /// Please note: This property should have been non-nullable! Since the specification file @@ -35,6 +39,14 @@ class PeopleUpdateItem { /// Person id. String id; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isFavorite; + /// Person visibility /// /// Please note: This property should have been non-nullable! Since the specification file @@ -56,8 +68,10 @@ class PeopleUpdateItem { @override bool operator ==(Object other) => identical(this, other) || other is PeopleUpdateItem && other.birthDate == birthDate && + other.color == color && other.featureFaceAssetId == featureFaceAssetId && other.id == id && + other.isFavorite == isFavorite && other.isHidden == isHidden && other.name == name; @@ -65,13 +79,15 @@ class PeopleUpdateItem { int get hashCode => // ignore: unnecessary_parenthesis (birthDate == null ? 0 : birthDate!.hashCode) + + (color == null ? 0 : color!.hashCode) + (featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) + (id.hashCode) + + (isFavorite == null ? 0 : isFavorite!.hashCode) + (isHidden == null ? 0 : isHidden!.hashCode) + (name == null ? 0 : name!.hashCode); @override - String toString() => 'PeopleUpdateItem[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, id=$id, isHidden=$isHidden, name=$name]'; + String toString() => 'PeopleUpdateItem[birthDate=$birthDate, color=$color, featureFaceAssetId=$featureFaceAssetId, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]'; Map toJson() { final json = {}; @@ -80,12 +96,22 @@ class PeopleUpdateItem { } else { // json[r'birthDate'] = null; } + if (this.color != null) { + json[r'color'] = this.color; + } else { + // json[r'color'] = null; + } if (this.featureFaceAssetId != null) { json[r'featureFaceAssetId'] = this.featureFaceAssetId; } else { // json[r'featureFaceAssetId'] = null; } json[r'id'] = this.id; + if (this.isFavorite != null) { + json[r'isFavorite'] = this.isFavorite; + } else { + // json[r'isFavorite'] = null; + } if (this.isHidden != null) { json[r'isHidden'] = this.isHidden; } else { @@ -109,8 +135,10 @@ class PeopleUpdateItem { return PeopleUpdateItem( birthDate: mapDateTime(json, r'birthDate', r''), + color: mapValueOfType(json, r'color'), featureFaceAssetId: mapValueOfType(json, r'featureFaceAssetId'), id: mapValueOfType(json, r'id')!, + isFavorite: mapValueOfType(json, r'isFavorite'), isHidden: mapValueOfType(json, r'isHidden'), name: mapValueOfType(json, r'name'), ); diff --git a/mobile/openapi/lib/model/person_create_dto.dart b/mobile/openapi/lib/model/person_create_dto.dart index 36bd6dfee9..87b426eaed 100644 --- a/mobile/openapi/lib/model/person_create_dto.dart +++ b/mobile/openapi/lib/model/person_create_dto.dart @@ -14,6 +14,8 @@ class PersonCreateDto { /// Returns a new [PersonCreateDto] instance. PersonCreateDto({ this.birthDate, + this.color, + this.isFavorite, this.isHidden, this.name, }); @@ -21,6 +23,16 @@ class PersonCreateDto { /// Person date of birth. Note: the mobile app cannot currently set the birth date to null. DateTime? birthDate; + String? color; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isFavorite; + /// Person visibility /// /// Please note: This property should have been non-nullable! Since the specification file @@ -42,6 +54,8 @@ class PersonCreateDto { @override bool operator ==(Object other) => identical(this, other) || other is PersonCreateDto && other.birthDate == birthDate && + other.color == color && + other.isFavorite == isFavorite && other.isHidden == isHidden && other.name == name; @@ -49,11 +63,13 @@ class PersonCreateDto { int get hashCode => // ignore: unnecessary_parenthesis (birthDate == null ? 0 : birthDate!.hashCode) + + (color == null ? 0 : color!.hashCode) + + (isFavorite == null ? 0 : isFavorite!.hashCode) + (isHidden == null ? 0 : isHidden!.hashCode) + (name == null ? 0 : name!.hashCode); @override - String toString() => 'PersonCreateDto[birthDate=$birthDate, isHidden=$isHidden, name=$name]'; + String toString() => 'PersonCreateDto[birthDate=$birthDate, color=$color, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]'; Map toJson() { final json = {}; @@ -62,6 +78,16 @@ class PersonCreateDto { } else { // json[r'birthDate'] = null; } + if (this.color != null) { + json[r'color'] = this.color; + } else { + // json[r'color'] = null; + } + if (this.isFavorite != null) { + json[r'isFavorite'] = this.isFavorite; + } else { + // json[r'isFavorite'] = null; + } if (this.isHidden != null) { json[r'isHidden'] = this.isHidden; } else { @@ -85,6 +111,8 @@ class PersonCreateDto { return PersonCreateDto( birthDate: mapDateTime(json, r'birthDate', r''), + color: mapValueOfType(json, r'color'), + isFavorite: mapValueOfType(json, r'isFavorite'), isHidden: mapValueOfType(json, r'isHidden'), name: mapValueOfType(json, r'name'), ); diff --git a/mobile/openapi/lib/model/person_response_dto.dart b/mobile/openapi/lib/model/person_response_dto.dart index 0b36fcde3b..c9ebb14c72 100644 --- a/mobile/openapi/lib/model/person_response_dto.dart +++ b/mobile/openapi/lib/model/person_response_dto.dart @@ -14,7 +14,9 @@ class PersonResponseDto { /// Returns a new [PersonResponseDto] instance. PersonResponseDto({ required this.birthDate, + this.color, required this.id, + this.isFavorite, required this.isHidden, required this.name, required this.thumbnailPath, @@ -23,8 +25,26 @@ class PersonResponseDto { DateTime? birthDate; + /// This property was added in v1.126.0 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? color; + String id; + /// This property was added in v1.126.0 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isFavorite; + bool isHidden; String name; @@ -43,7 +63,9 @@ class PersonResponseDto { @override bool operator ==(Object other) => identical(this, other) || other is PersonResponseDto && other.birthDate == birthDate && + other.color == color && other.id == id && + other.isFavorite == isFavorite && other.isHidden == isHidden && other.name == name && other.thumbnailPath == thumbnailPath && @@ -53,14 +75,16 @@ class PersonResponseDto { int get hashCode => // ignore: unnecessary_parenthesis (birthDate == null ? 0 : birthDate!.hashCode) + + (color == null ? 0 : color!.hashCode) + (id.hashCode) + + (isFavorite == null ? 0 : isFavorite!.hashCode) + (isHidden.hashCode) + (name.hashCode) + (thumbnailPath.hashCode) + (updatedAt == null ? 0 : updatedAt!.hashCode); @override - String toString() => 'PersonResponseDto[birthDate=$birthDate, id=$id, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; + String toString() => 'PersonResponseDto[birthDate=$birthDate, color=$color, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -68,8 +92,18 @@ class PersonResponseDto { json[r'birthDate'] = _dateFormatter.format(this.birthDate!.toUtc()); } else { // json[r'birthDate'] = null; + } + if (this.color != null) { + json[r'color'] = this.color; + } else { + // json[r'color'] = null; } json[r'id'] = this.id; + if (this.isFavorite != null) { + json[r'isFavorite'] = this.isFavorite; + } else { + // json[r'isFavorite'] = null; + } json[r'isHidden'] = this.isHidden; json[r'name'] = this.name; json[r'thumbnailPath'] = this.thumbnailPath; @@ -91,7 +125,9 @@ class PersonResponseDto { return PersonResponseDto( birthDate: mapDateTime(json, r'birthDate', r''), + color: mapValueOfType(json, r'color'), id: mapValueOfType(json, r'id')!, + isFavorite: mapValueOfType(json, r'isFavorite'), isHidden: mapValueOfType(json, r'isHidden')!, name: mapValueOfType(json, r'name')!, thumbnailPath: mapValueOfType(json, r'thumbnailPath')!, diff --git a/mobile/openapi/lib/model/person_update_dto.dart b/mobile/openapi/lib/model/person_update_dto.dart index 51a7ea25d0..6736b4e177 100644 --- a/mobile/openapi/lib/model/person_update_dto.dart +++ b/mobile/openapi/lib/model/person_update_dto.dart @@ -14,7 +14,9 @@ class PersonUpdateDto { /// Returns a new [PersonUpdateDto] instance. PersonUpdateDto({ this.birthDate, + this.color, this.featureFaceAssetId, + this.isFavorite, this.isHidden, this.name, }); @@ -22,6 +24,8 @@ class PersonUpdateDto { /// Person date of birth. Note: the mobile app cannot currently set the birth date to null. DateTime? birthDate; + String? color; + /// Asset is used to get the feature face thumbnail. /// /// Please note: This property should have been non-nullable! Since the specification file @@ -31,6 +35,14 @@ class PersonUpdateDto { /// String? featureFaceAssetId; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isFavorite; + /// Person visibility /// /// Please note: This property should have been non-nullable! Since the specification file @@ -52,7 +64,9 @@ class PersonUpdateDto { @override bool operator ==(Object other) => identical(this, other) || other is PersonUpdateDto && other.birthDate == birthDate && + other.color == color && other.featureFaceAssetId == featureFaceAssetId && + other.isFavorite == isFavorite && other.isHidden == isHidden && other.name == name; @@ -60,12 +74,14 @@ class PersonUpdateDto { int get hashCode => // ignore: unnecessary_parenthesis (birthDate == null ? 0 : birthDate!.hashCode) + + (color == null ? 0 : color!.hashCode) + (featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) + + (isFavorite == null ? 0 : isFavorite!.hashCode) + (isHidden == null ? 0 : isHidden!.hashCode) + (name == null ? 0 : name!.hashCode); @override - String toString() => 'PersonUpdateDto[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, isHidden=$isHidden, name=$name]'; + String toString() => 'PersonUpdateDto[birthDate=$birthDate, color=$color, featureFaceAssetId=$featureFaceAssetId, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]'; Map toJson() { final json = {}; @@ -74,11 +90,21 @@ class PersonUpdateDto { } else { // json[r'birthDate'] = null; } + if (this.color != null) { + json[r'color'] = this.color; + } else { + // json[r'color'] = null; + } if (this.featureFaceAssetId != null) { json[r'featureFaceAssetId'] = this.featureFaceAssetId; } else { // json[r'featureFaceAssetId'] = null; } + if (this.isFavorite != null) { + json[r'isFavorite'] = this.isFavorite; + } else { + // json[r'isFavorite'] = null; + } if (this.isHidden != null) { json[r'isHidden'] = this.isHidden; } else { @@ -102,7 +128,9 @@ class PersonUpdateDto { return PersonUpdateDto( birthDate: mapDateTime(json, r'birthDate', r''), + color: mapValueOfType(json, r'color'), featureFaceAssetId: mapValueOfType(json, r'featureFaceAssetId'), + isFavorite: mapValueOfType(json, r'isFavorite'), isHidden: mapValueOfType(json, r'isHidden'), name: mapValueOfType(json, r'name'), ); diff --git a/mobile/openapi/lib/model/person_with_faces_response_dto.dart b/mobile/openapi/lib/model/person_with_faces_response_dto.dart index b14bad7895..0bd38b0870 100644 --- a/mobile/openapi/lib/model/person_with_faces_response_dto.dart +++ b/mobile/openapi/lib/model/person_with_faces_response_dto.dart @@ -14,8 +14,10 @@ class PersonWithFacesResponseDto { /// Returns a new [PersonWithFacesResponseDto] instance. PersonWithFacesResponseDto({ required this.birthDate, + this.color, this.faces = const [], required this.id, + this.isFavorite, required this.isHidden, required this.name, required this.thumbnailPath, @@ -24,10 +26,28 @@ class PersonWithFacesResponseDto { DateTime? birthDate; + /// This property was added in v1.126.0 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? color; + List faces; String id; + /// This property was added in v1.126.0 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isFavorite; + bool isHidden; String name; @@ -46,8 +66,10 @@ class PersonWithFacesResponseDto { @override bool operator ==(Object other) => identical(this, other) || other is PersonWithFacesResponseDto && other.birthDate == birthDate && + other.color == color && _deepEquality.equals(other.faces, faces) && other.id == id && + other.isFavorite == isFavorite && other.isHidden == isHidden && other.name == name && other.thumbnailPath == thumbnailPath && @@ -57,15 +79,17 @@ class PersonWithFacesResponseDto { int get hashCode => // ignore: unnecessary_parenthesis (birthDate == null ? 0 : birthDate!.hashCode) + + (color == null ? 0 : color!.hashCode) + (faces.hashCode) + (id.hashCode) + + (isFavorite == null ? 0 : isFavorite!.hashCode) + (isHidden.hashCode) + (name.hashCode) + (thumbnailPath.hashCode) + (updatedAt == null ? 0 : updatedAt!.hashCode); @override - String toString() => 'PersonWithFacesResponseDto[birthDate=$birthDate, faces=$faces, id=$id, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; + String toString() => 'PersonWithFacesResponseDto[birthDate=$birthDate, color=$color, faces=$faces, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -73,9 +97,19 @@ class PersonWithFacesResponseDto { json[r'birthDate'] = _dateFormatter.format(this.birthDate!.toUtc()); } else { // json[r'birthDate'] = null; + } + if (this.color != null) { + json[r'color'] = this.color; + } else { + // json[r'color'] = null; } json[r'faces'] = this.faces; json[r'id'] = this.id; + if (this.isFavorite != null) { + json[r'isFavorite'] = this.isFavorite; + } else { + // json[r'isFavorite'] = null; + } json[r'isHidden'] = this.isHidden; json[r'name'] = this.name; json[r'thumbnailPath'] = this.thumbnailPath; @@ -97,8 +131,10 @@ class PersonWithFacesResponseDto { return PersonWithFacesResponseDto( birthDate: mapDateTime(json, r'birthDate', r''), + color: mapValueOfType(json, r'color'), faces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'faces']), id: mapValueOfType(json, r'id')!, + isFavorite: mapValueOfType(json, r'isFavorite'), isHidden: mapValueOfType(json, r'isHidden')!, name: mapValueOfType(json, r'name')!, thumbnailPath: mapValueOfType(json, r'thumbnailPath')!, diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart index 3fcab05bbb..10727ec10d 100644 --- a/mobile/openapi/lib/model/random_search_dto.dart +++ b/mobile/openapi/lib/model/random_search_dto.dart @@ -30,8 +30,10 @@ class RandomSearchDto { this.make, this.model, this.personIds = const [], + this.rating, this.size, this.state, + this.tagIds = const [], this.takenAfter, this.takenBefore, this.trashedAfter, @@ -146,6 +148,16 @@ class RandomSearchDto { List personIds; + /// Minimum value: -1 + /// Maximum value: 5 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + num? rating; + /// Minimum value: 1 /// Maximum value: 1000 /// @@ -158,6 +170,8 @@ class RandomSearchDto { String? state; + List tagIds; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -267,8 +281,10 @@ class RandomSearchDto { other.make == make && other.model == model && _deepEquality.equals(other.personIds, personIds) && + other.rating == rating && other.size == size && other.state == state && + _deepEquality.equals(other.tagIds, tagIds) && other.takenAfter == takenAfter && other.takenBefore == takenBefore && other.trashedAfter == trashedAfter && @@ -302,8 +318,10 @@ class RandomSearchDto { (make == null ? 0 : make!.hashCode) + (model == null ? 0 : model!.hashCode) + (personIds.hashCode) + + (rating == null ? 0 : rating!.hashCode) + (size == null ? 0 : size!.hashCode) + (state == null ? 0 : state!.hashCode) + + (tagIds.hashCode) + (takenAfter == null ? 0 : takenAfter!.hashCode) + (takenBefore == null ? 0 : takenBefore!.hashCode) + (trashedAfter == null ? 0 : trashedAfter!.hashCode) + @@ -318,7 +336,7 @@ class RandomSearchDto { (withStacked == null ? 0 : withStacked!.hashCode); @override - String toString() => 'RandomSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, personIds=$personIds, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; + String toString() => 'RandomSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, personIds=$personIds, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; Map toJson() { final json = {}; @@ -403,6 +421,11 @@ class RandomSearchDto { // json[r'model'] = null; } json[r'personIds'] = this.personIds; + if (this.rating != null) { + json[r'rating'] = this.rating; + } else { + // json[r'rating'] = null; + } if (this.size != null) { json[r'size'] = this.size; } else { @@ -413,6 +436,7 @@ class RandomSearchDto { } else { // json[r'state'] = null; } + json[r'tagIds'] = this.tagIds; if (this.takenAfter != null) { json[r'takenAfter'] = this.takenAfter!.toUtc().toIso8601String(); } else { @@ -500,8 +524,12 @@ class RandomSearchDto { personIds: json[r'personIds'] is Iterable ? (json[r'personIds'] as Iterable).cast().toList(growable: false) : const [], + rating: num.parse('${json[r'rating']}'), size: num.parse('${json[r'size']}'), state: mapValueOfType(json, r'state'), + tagIds: json[r'tagIds'] is Iterable + ? (json[r'tagIds'] as Iterable).cast().toList(growable: false) + : const [], takenAfter: mapDateTime(json, r'takenAfter', r''), takenBefore: mapDateTime(json, r'takenBefore', r''), trashedAfter: mapDateTime(json, r'trashedAfter', r''), diff --git a/mobile/openapi/lib/model/shared_links_response.dart b/mobile/openapi/lib/model/shared_links_response.dart new file mode 100644 index 0000000000..80875e6174 --- /dev/null +++ b/mobile/openapi/lib/model/shared_links_response.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SharedLinksResponse { + /// Returns a new [SharedLinksResponse] instance. + SharedLinksResponse({ + this.enabled = true, + this.sidebarWeb = false, + }); + + bool enabled; + + bool sidebarWeb; + + @override + bool operator ==(Object other) => identical(this, other) || other is SharedLinksResponse && + other.enabled == enabled && + other.sidebarWeb == sidebarWeb; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (enabled.hashCode) + + (sidebarWeb.hashCode); + + @override + String toString() => 'SharedLinksResponse[enabled=$enabled, sidebarWeb=$sidebarWeb]'; + + Map toJson() { + final json = {}; + json[r'enabled'] = this.enabled; + json[r'sidebarWeb'] = this.sidebarWeb; + return json; + } + + /// Returns a new [SharedLinksResponse] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SharedLinksResponse? fromJson(dynamic value) { + upgradeDto(value, "SharedLinksResponse"); + if (value is Map) { + final json = value.cast(); + + return SharedLinksResponse( + enabled: mapValueOfType(json, r'enabled')!, + sidebarWeb: mapValueOfType(json, r'sidebarWeb')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SharedLinksResponse.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SharedLinksResponse.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SharedLinksResponse-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SharedLinksResponse.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'enabled', + 'sidebarWeb', + }; +} + diff --git a/mobile/openapi/lib/model/shared_links_update.dart b/mobile/openapi/lib/model/shared_links_update.dart new file mode 100644 index 0000000000..5d9eda3001 --- /dev/null +++ b/mobile/openapi/lib/model/shared_links_update.dart @@ -0,0 +1,125 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SharedLinksUpdate { + /// Returns a new [SharedLinksUpdate] instance. + SharedLinksUpdate({ + this.enabled, + this.sidebarWeb, + }); + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? enabled; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? sidebarWeb; + + @override + bool operator ==(Object other) => identical(this, other) || other is SharedLinksUpdate && + other.enabled == enabled && + other.sidebarWeb == sidebarWeb; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (enabled == null ? 0 : enabled!.hashCode) + + (sidebarWeb == null ? 0 : sidebarWeb!.hashCode); + + @override + String toString() => 'SharedLinksUpdate[enabled=$enabled, sidebarWeb=$sidebarWeb]'; + + Map toJson() { + final json = {}; + if (this.enabled != null) { + json[r'enabled'] = this.enabled; + } else { + // json[r'enabled'] = null; + } + if (this.sidebarWeb != null) { + json[r'sidebarWeb'] = this.sidebarWeb; + } else { + // json[r'sidebarWeb'] = null; + } + return json; + } + + /// Returns a new [SharedLinksUpdate] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SharedLinksUpdate? fromJson(dynamic value) { + upgradeDto(value, "SharedLinksUpdate"); + if (value is Map) { + final json = value.cast(); + + return SharedLinksUpdate( + enabled: mapValueOfType(json, r'enabled'), + sidebarWeb: mapValueOfType(json, r'sidebarWeb'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SharedLinksUpdate.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SharedLinksUpdate.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SharedLinksUpdate-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SharedLinksUpdate.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/smart_search_dto.dart b/mobile/openapi/lib/model/smart_search_dto.dart index 4e1408cafa..f377c23f22 100644 --- a/mobile/openapi/lib/model/smart_search_dto.dart +++ b/mobile/openapi/lib/model/smart_search_dto.dart @@ -32,8 +32,10 @@ class SmartSearchDto { this.page, this.personIds = const [], required this.query, + this.rating, this.size, this.state, + this.tagIds = const [], this.takenAfter, this.takenBefore, this.trashedAfter, @@ -157,6 +159,16 @@ class SmartSearchDto { String query; + /// Minimum value: -1 + /// Maximum value: 5 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + num? rating; + /// Minimum value: 1 /// Maximum value: 1000 /// @@ -169,6 +181,8 @@ class SmartSearchDto { String? state; + List tagIds; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -264,8 +278,10 @@ class SmartSearchDto { other.page == page && _deepEquality.equals(other.personIds, personIds) && other.query == query && + other.rating == rating && other.size == size && other.state == state && + _deepEquality.equals(other.tagIds, tagIds) && other.takenAfter == takenAfter && other.takenBefore == takenBefore && other.trashedAfter == trashedAfter && @@ -299,8 +315,10 @@ class SmartSearchDto { (page == null ? 0 : page!.hashCode) + (personIds.hashCode) + (query.hashCode) + + (rating == null ? 0 : rating!.hashCode) + (size == null ? 0 : size!.hashCode) + (state == null ? 0 : state!.hashCode) + + (tagIds.hashCode) + (takenAfter == null ? 0 : takenAfter!.hashCode) + (takenBefore == null ? 0 : takenBefore!.hashCode) + (trashedAfter == null ? 0 : trashedAfter!.hashCode) + @@ -313,7 +331,7 @@ class SmartSearchDto { (withExif == null ? 0 : withExif!.hashCode); @override - String toString() => 'SmartSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, query=$query, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif]'; + String toString() => 'SmartSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, query=$query, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif]'; Map toJson() { final json = {}; @@ -404,6 +422,11 @@ class SmartSearchDto { } json[r'personIds'] = this.personIds; json[r'query'] = this.query; + if (this.rating != null) { + json[r'rating'] = this.rating; + } else { + // json[r'rating'] = null; + } if (this.size != null) { json[r'size'] = this.size; } else { @@ -414,6 +437,7 @@ class SmartSearchDto { } else { // json[r'state'] = null; } + json[r'tagIds'] = this.tagIds; if (this.takenAfter != null) { json[r'takenAfter'] = this.takenAfter!.toUtc().toIso8601String(); } else { @@ -493,8 +517,12 @@ class SmartSearchDto { ? (json[r'personIds'] as Iterable).cast().toList(growable: false) : const [], query: mapValueOfType(json, r'query')!, + rating: num.parse('${json[r'rating']}'), size: num.parse('${json[r'size']}'), state: mapValueOfType(json, r'state'), + tagIds: json[r'tagIds'] is Iterable + ? (json[r'tagIds'] as Iterable).cast().toList(growable: false) + : const [], takenAfter: mapDateTime(json, r'takenAfter', r''), takenBefore: mapDateTime(json, r'takenBefore', r''), trashedAfter: mapDateTime(json, r'trashedAfter', r''), diff --git a/mobile/openapi/lib/model/source_type.dart b/mobile/openapi/lib/model/source_type.dart index 13c450b010..4da5aba495 100644 --- a/mobile/openapi/lib/model/source_type.dart +++ b/mobile/openapi/lib/model/source_type.dart @@ -25,11 +25,13 @@ class SourceType { static const machineLearning = SourceType._(r'machine-learning'); static const exif = SourceType._(r'exif'); + static const manual = SourceType._(r'manual'); /// List of all possible values in this [enum][SourceType]. static const values = [ machineLearning, exif, + manual, ]; static SourceType? fromJson(dynamic value) => SourceTypeTypeTransformer().decode(value); @@ -70,6 +72,7 @@ class SourceTypeTypeTransformer { switch (data) { case r'machine-learning': return SourceType.machineLearning; case r'exif': return SourceType.exif; + case r'manual': return SourceType.manual; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/mobile/openapi/lib/model/sync_ack_delete_dto.dart b/mobile/openapi/lib/model/sync_ack_delete_dto.dart new file mode 100644 index 0000000000..998f812f2e --- /dev/null +++ b/mobile/openapi/lib/model/sync_ack_delete_dto.dart @@ -0,0 +1,98 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncAckDeleteDto { + /// Returns a new [SyncAckDeleteDto] instance. + SyncAckDeleteDto({ + this.types = const [], + }); + + List types; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAckDeleteDto && + _deepEquality.equals(other.types, types); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (types.hashCode); + + @override + String toString() => 'SyncAckDeleteDto[types=$types]'; + + Map toJson() { + final json = {}; + json[r'types'] = this.types; + return json; + } + + /// Returns a new [SyncAckDeleteDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAckDeleteDto? fromJson(dynamic value) { + upgradeDto(value, "SyncAckDeleteDto"); + if (value is Map) { + final json = value.cast(); + + return SyncAckDeleteDto( + types: SyncEntityType.listFromJson(json[r'types']), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncAckDeleteDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncAckDeleteDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAckDeleteDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncAckDeleteDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/sync_ack_dto.dart b/mobile/openapi/lib/model/sync_ack_dto.dart new file mode 100644 index 0000000000..c7fafa17d2 --- /dev/null +++ b/mobile/openapi/lib/model/sync_ack_dto.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncAckDto { + /// Returns a new [SyncAckDto] instance. + SyncAckDto({ + required this.ack, + required this.type, + }); + + String ack; + + SyncEntityType type; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAckDto && + other.ack == ack && + other.type == type; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (ack.hashCode) + + (type.hashCode); + + @override + String toString() => 'SyncAckDto[ack=$ack, type=$type]'; + + Map toJson() { + final json = {}; + json[r'ack'] = this.ack; + json[r'type'] = this.type; + return json; + } + + /// Returns a new [SyncAckDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAckDto? fromJson(dynamic value) { + upgradeDto(value, "SyncAckDto"); + if (value is Map) { + final json = value.cast(); + + return SyncAckDto( + ack: mapValueOfType(json, r'ack')!, + type: SyncEntityType.fromJson(json[r'type'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncAckDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncAckDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAckDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncAckDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'ack', + 'type', + }; +} + diff --git a/mobile/openapi/lib/model/sync_ack_set_dto.dart b/mobile/openapi/lib/model/sync_ack_set_dto.dart new file mode 100644 index 0000000000..0d9eedc389 --- /dev/null +++ b/mobile/openapi/lib/model/sync_ack_set_dto.dart @@ -0,0 +1,101 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncAckSetDto { + /// Returns a new [SyncAckSetDto] instance. + SyncAckSetDto({ + this.acks = const [], + }); + + List acks; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAckSetDto && + _deepEquality.equals(other.acks, acks); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (acks.hashCode); + + @override + String toString() => 'SyncAckSetDto[acks=$acks]'; + + Map toJson() { + final json = {}; + json[r'acks'] = this.acks; + return json; + } + + /// Returns a new [SyncAckSetDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAckSetDto? fromJson(dynamic value) { + upgradeDto(value, "SyncAckSetDto"); + if (value is Map) { + final json = value.cast(); + + return SyncAckSetDto( + acks: json[r'acks'] is Iterable + ? (json[r'acks'] as Iterable).cast().toList(growable: false) + : const [], + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncAckSetDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncAckSetDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAckSetDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncAckSetDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'acks', + }; +} + diff --git a/mobile/openapi/lib/model/sync_asset_delete_v1.dart b/mobile/openapi/lib/model/sync_asset_delete_v1.dart new file mode 100644 index 0000000000..c1787caf04 --- /dev/null +++ b/mobile/openapi/lib/model/sync_asset_delete_v1.dart @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncAssetDeleteV1 { + /// Returns a new [SyncAssetDeleteV1] instance. + SyncAssetDeleteV1({ + required this.assetId, + }); + + String assetId; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAssetDeleteV1 && + other.assetId == assetId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode); + + @override + String toString() => 'SyncAssetDeleteV1[assetId=$assetId]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + return json; + } + + /// Returns a new [SyncAssetDeleteV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAssetDeleteV1? fromJson(dynamic value) { + upgradeDto(value, "SyncAssetDeleteV1"); + if (value is Map) { + final json = value.cast(); + + return SyncAssetDeleteV1( + assetId: mapValueOfType(json, r'assetId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncAssetDeleteV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncAssetDeleteV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAssetDeleteV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncAssetDeleteV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + }; +} + diff --git a/mobile/openapi/lib/model/sync_asset_exif_v1.dart b/mobile/openapi/lib/model/sync_asset_exif_v1.dart new file mode 100644 index 0000000000..b0fef28b76 --- /dev/null +++ b/mobile/openapi/lib/model/sync_asset_exif_v1.dart @@ -0,0 +1,387 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncAssetExifV1 { + /// Returns a new [SyncAssetExifV1] instance. + SyncAssetExifV1({ + required this.assetId, + required this.city, + required this.country, + required this.dateTimeOriginal, + required this.description, + required this.exifImageHeight, + required this.exifImageWidth, + required this.exposureTime, + required this.fNumber, + required this.fileSizeInByte, + required this.focalLength, + required this.fps, + required this.iso, + required this.latitude, + required this.lensModel, + required this.longitude, + required this.make, + required this.model, + required this.modifyDate, + required this.orientation, + required this.profileDescription, + required this.projectionType, + required this.rating, + required this.state, + required this.timeZone, + }); + + String assetId; + + String? city; + + String? country; + + DateTime? dateTimeOriginal; + + String? description; + + int? exifImageHeight; + + int? exifImageWidth; + + String? exposureTime; + + int? fNumber; + + int? fileSizeInByte; + + int? focalLength; + + int? fps; + + int? iso; + + int? latitude; + + String? lensModel; + + int? longitude; + + String? make; + + String? model; + + DateTime? modifyDate; + + String? orientation; + + String? profileDescription; + + String? projectionType; + + int? rating; + + String? state; + + String? timeZone; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAssetExifV1 && + other.assetId == assetId && + other.city == city && + other.country == country && + other.dateTimeOriginal == dateTimeOriginal && + other.description == description && + other.exifImageHeight == exifImageHeight && + other.exifImageWidth == exifImageWidth && + other.exposureTime == exposureTime && + other.fNumber == fNumber && + other.fileSizeInByte == fileSizeInByte && + other.focalLength == focalLength && + other.fps == fps && + other.iso == iso && + other.latitude == latitude && + other.lensModel == lensModel && + other.longitude == longitude && + other.make == make && + other.model == model && + other.modifyDate == modifyDate && + other.orientation == orientation && + other.profileDescription == profileDescription && + other.projectionType == projectionType && + other.rating == rating && + other.state == state && + other.timeZone == timeZone; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode) + + (city == null ? 0 : city!.hashCode) + + (country == null ? 0 : country!.hashCode) + + (dateTimeOriginal == null ? 0 : dateTimeOriginal!.hashCode) + + (description == null ? 0 : description!.hashCode) + + (exifImageHeight == null ? 0 : exifImageHeight!.hashCode) + + (exifImageWidth == null ? 0 : exifImageWidth!.hashCode) + + (exposureTime == null ? 0 : exposureTime!.hashCode) + + (fNumber == null ? 0 : fNumber!.hashCode) + + (fileSizeInByte == null ? 0 : fileSizeInByte!.hashCode) + + (focalLength == null ? 0 : focalLength!.hashCode) + + (fps == null ? 0 : fps!.hashCode) + + (iso == null ? 0 : iso!.hashCode) + + (latitude == null ? 0 : latitude!.hashCode) + + (lensModel == null ? 0 : lensModel!.hashCode) + + (longitude == null ? 0 : longitude!.hashCode) + + (make == null ? 0 : make!.hashCode) + + (model == null ? 0 : model!.hashCode) + + (modifyDate == null ? 0 : modifyDate!.hashCode) + + (orientation == null ? 0 : orientation!.hashCode) + + (profileDescription == null ? 0 : profileDescription!.hashCode) + + (projectionType == null ? 0 : projectionType!.hashCode) + + (rating == null ? 0 : rating!.hashCode) + + (state == null ? 0 : state!.hashCode) + + (timeZone == null ? 0 : timeZone!.hashCode); + + @override + String toString() => 'SyncAssetExifV1[assetId=$assetId, city=$city, country=$country, dateTimeOriginal=$dateTimeOriginal, description=$description, exifImageHeight=$exifImageHeight, exifImageWidth=$exifImageWidth, exposureTime=$exposureTime, fNumber=$fNumber, fileSizeInByte=$fileSizeInByte, focalLength=$focalLength, fps=$fps, iso=$iso, latitude=$latitude, lensModel=$lensModel, longitude=$longitude, make=$make, model=$model, modifyDate=$modifyDate, orientation=$orientation, profileDescription=$profileDescription, projectionType=$projectionType, rating=$rating, state=$state, timeZone=$timeZone]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + if (this.city != null) { + json[r'city'] = this.city; + } else { + // json[r'city'] = null; + } + if (this.country != null) { + json[r'country'] = this.country; + } else { + // json[r'country'] = null; + } + if (this.dateTimeOriginal != null) { + json[r'dateTimeOriginal'] = this.dateTimeOriginal!.toUtc().toIso8601String(); + } else { + // json[r'dateTimeOriginal'] = null; + } + if (this.description != null) { + json[r'description'] = this.description; + } else { + // json[r'description'] = null; + } + if (this.exifImageHeight != null) { + json[r'exifImageHeight'] = this.exifImageHeight; + } else { + // json[r'exifImageHeight'] = null; + } + if (this.exifImageWidth != null) { + json[r'exifImageWidth'] = this.exifImageWidth; + } else { + // json[r'exifImageWidth'] = null; + } + if (this.exposureTime != null) { + json[r'exposureTime'] = this.exposureTime; + } else { + // json[r'exposureTime'] = null; + } + if (this.fNumber != null) { + json[r'fNumber'] = this.fNumber; + } else { + // json[r'fNumber'] = null; + } + if (this.fileSizeInByte != null) { + json[r'fileSizeInByte'] = this.fileSizeInByte; + } else { + // json[r'fileSizeInByte'] = null; + } + if (this.focalLength != null) { + json[r'focalLength'] = this.focalLength; + } else { + // json[r'focalLength'] = null; + } + if (this.fps != null) { + json[r'fps'] = this.fps; + } else { + // json[r'fps'] = null; + } + if (this.iso != null) { + json[r'iso'] = this.iso; + } else { + // json[r'iso'] = null; + } + if (this.latitude != null) { + json[r'latitude'] = this.latitude; + } else { + // json[r'latitude'] = null; + } + if (this.lensModel != null) { + json[r'lensModel'] = this.lensModel; + } else { + // json[r'lensModel'] = null; + } + if (this.longitude != null) { + json[r'longitude'] = this.longitude; + } else { + // json[r'longitude'] = null; + } + if (this.make != null) { + json[r'make'] = this.make; + } else { + // json[r'make'] = null; + } + if (this.model != null) { + json[r'model'] = this.model; + } else { + // json[r'model'] = null; + } + if (this.modifyDate != null) { + json[r'modifyDate'] = this.modifyDate!.toUtc().toIso8601String(); + } else { + // json[r'modifyDate'] = null; + } + if (this.orientation != null) { + json[r'orientation'] = this.orientation; + } else { + // json[r'orientation'] = null; + } + if (this.profileDescription != null) { + json[r'profileDescription'] = this.profileDescription; + } else { + // json[r'profileDescription'] = null; + } + if (this.projectionType != null) { + json[r'projectionType'] = this.projectionType; + } else { + // json[r'projectionType'] = null; + } + if (this.rating != null) { + json[r'rating'] = this.rating; + } else { + // json[r'rating'] = null; + } + if (this.state != null) { + json[r'state'] = this.state; + } else { + // json[r'state'] = null; + } + if (this.timeZone != null) { + json[r'timeZone'] = this.timeZone; + } else { + // json[r'timeZone'] = null; + } + return json; + } + + /// Returns a new [SyncAssetExifV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAssetExifV1? fromJson(dynamic value) { + upgradeDto(value, "SyncAssetExifV1"); + if (value is Map) { + final json = value.cast(); + + return SyncAssetExifV1( + assetId: mapValueOfType(json, r'assetId')!, + city: mapValueOfType(json, r'city'), + country: mapValueOfType(json, r'country'), + dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', r''), + description: mapValueOfType(json, r'description'), + exifImageHeight: mapValueOfType(json, r'exifImageHeight'), + exifImageWidth: mapValueOfType(json, r'exifImageWidth'), + exposureTime: mapValueOfType(json, r'exposureTime'), + fNumber: mapValueOfType(json, r'fNumber'), + fileSizeInByte: mapValueOfType(json, r'fileSizeInByte'), + focalLength: mapValueOfType(json, r'focalLength'), + fps: mapValueOfType(json, r'fps'), + iso: mapValueOfType(json, r'iso'), + latitude: mapValueOfType(json, r'latitude'), + lensModel: mapValueOfType(json, r'lensModel'), + longitude: mapValueOfType(json, r'longitude'), + make: mapValueOfType(json, r'make'), + model: mapValueOfType(json, r'model'), + modifyDate: mapDateTime(json, r'modifyDate', r''), + orientation: mapValueOfType(json, r'orientation'), + profileDescription: mapValueOfType(json, r'profileDescription'), + projectionType: mapValueOfType(json, r'projectionType'), + rating: mapValueOfType(json, r'rating'), + state: mapValueOfType(json, r'state'), + timeZone: mapValueOfType(json, r'timeZone'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncAssetExifV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncAssetExifV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAssetExifV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncAssetExifV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'city', + 'country', + 'dateTimeOriginal', + 'description', + 'exifImageHeight', + 'exifImageWidth', + 'exposureTime', + 'fNumber', + 'fileSizeInByte', + 'focalLength', + 'fps', + 'iso', + 'latitude', + 'lensModel', + 'longitude', + 'make', + 'model', + 'modifyDate', + 'orientation', + 'profileDescription', + 'projectionType', + 'rating', + 'state', + 'timeZone', + }; +} + diff --git a/mobile/openapi/lib/model/sync_asset_v1.dart b/mobile/openapi/lib/model/sync_asset_v1.dart new file mode 100644 index 0000000000..6f9d7d7eaf --- /dev/null +++ b/mobile/openapi/lib/model/sync_asset_v1.dart @@ -0,0 +1,279 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncAssetV1 { + /// Returns a new [SyncAssetV1] instance. + SyncAssetV1({ + required this.checksum, + required this.deletedAt, + required this.fileCreatedAt, + required this.fileModifiedAt, + required this.id, + required this.isFavorite, + required this.isVisible, + required this.localDateTime, + required this.ownerId, + required this.thumbhash, + required this.type, + }); + + String checksum; + + DateTime? deletedAt; + + DateTime? fileCreatedAt; + + DateTime? fileModifiedAt; + + String id; + + bool isFavorite; + + bool isVisible; + + DateTime? localDateTime; + + String ownerId; + + String? thumbhash; + + SyncAssetV1TypeEnum type; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAssetV1 && + other.checksum == checksum && + other.deletedAt == deletedAt && + other.fileCreatedAt == fileCreatedAt && + other.fileModifiedAt == fileModifiedAt && + other.id == id && + other.isFavorite == isFavorite && + other.isVisible == isVisible && + other.localDateTime == localDateTime && + other.ownerId == ownerId && + other.thumbhash == thumbhash && + other.type == type; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (checksum.hashCode) + + (deletedAt == null ? 0 : deletedAt!.hashCode) + + (fileCreatedAt == null ? 0 : fileCreatedAt!.hashCode) + + (fileModifiedAt == null ? 0 : fileModifiedAt!.hashCode) + + (id.hashCode) + + (isFavorite.hashCode) + + (isVisible.hashCode) + + (localDateTime == null ? 0 : localDateTime!.hashCode) + + (ownerId.hashCode) + + (thumbhash == null ? 0 : thumbhash!.hashCode) + + (type.hashCode); + + @override + String toString() => 'SyncAssetV1[checksum=$checksum, deletedAt=$deletedAt, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isFavorite=$isFavorite, isVisible=$isVisible, localDateTime=$localDateTime, ownerId=$ownerId, thumbhash=$thumbhash, type=$type]'; + + Map toJson() { + final json = {}; + json[r'checksum'] = this.checksum; + if (this.deletedAt != null) { + json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String(); + } else { + // json[r'deletedAt'] = null; + } + if (this.fileCreatedAt != null) { + json[r'fileCreatedAt'] = this.fileCreatedAt!.toUtc().toIso8601String(); + } else { + // json[r'fileCreatedAt'] = null; + } + if (this.fileModifiedAt != null) { + json[r'fileModifiedAt'] = this.fileModifiedAt!.toUtc().toIso8601String(); + } else { + // json[r'fileModifiedAt'] = null; + } + json[r'id'] = this.id; + json[r'isFavorite'] = this.isFavorite; + json[r'isVisible'] = this.isVisible; + if (this.localDateTime != null) { + json[r'localDateTime'] = this.localDateTime!.toUtc().toIso8601String(); + } else { + // json[r'localDateTime'] = null; + } + json[r'ownerId'] = this.ownerId; + if (this.thumbhash != null) { + json[r'thumbhash'] = this.thumbhash; + } else { + // json[r'thumbhash'] = null; + } + json[r'type'] = this.type; + return json; + } + + /// Returns a new [SyncAssetV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAssetV1? fromJson(dynamic value) { + upgradeDto(value, "SyncAssetV1"); + if (value is Map) { + final json = value.cast(); + + return SyncAssetV1( + checksum: mapValueOfType(json, r'checksum')!, + deletedAt: mapDateTime(json, r'deletedAt', r''), + fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r''), + fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r''), + id: mapValueOfType(json, r'id')!, + isFavorite: mapValueOfType(json, r'isFavorite')!, + isVisible: mapValueOfType(json, r'isVisible')!, + localDateTime: mapDateTime(json, r'localDateTime', r''), + ownerId: mapValueOfType(json, r'ownerId')!, + thumbhash: mapValueOfType(json, r'thumbhash'), + type: SyncAssetV1TypeEnum.fromJson(json[r'type'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncAssetV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncAssetV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAssetV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncAssetV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'checksum', + 'deletedAt', + 'fileCreatedAt', + 'fileModifiedAt', + 'id', + 'isFavorite', + 'isVisible', + 'localDateTime', + 'ownerId', + 'thumbhash', + 'type', + }; +} + + +class SyncAssetV1TypeEnum { + /// Instantiate a new enum with the provided [value]. + const SyncAssetV1TypeEnum._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const IMAGE = SyncAssetV1TypeEnum._(r'IMAGE'); + static const VIDEO = SyncAssetV1TypeEnum._(r'VIDEO'); + static const AUDIO = SyncAssetV1TypeEnum._(r'AUDIO'); + static const OTHER = SyncAssetV1TypeEnum._(r'OTHER'); + + /// List of all possible values in this [enum][SyncAssetV1TypeEnum]. + static const values = [ + IMAGE, + VIDEO, + AUDIO, + OTHER, + ]; + + static SyncAssetV1TypeEnum? fromJson(dynamic value) => SyncAssetV1TypeEnumTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncAssetV1TypeEnum.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [SyncAssetV1TypeEnum] to String, +/// and [decode] dynamic data back to [SyncAssetV1TypeEnum]. +class SyncAssetV1TypeEnumTypeTransformer { + factory SyncAssetV1TypeEnumTypeTransformer() => _instance ??= const SyncAssetV1TypeEnumTypeTransformer._(); + + const SyncAssetV1TypeEnumTypeTransformer._(); + + String encode(SyncAssetV1TypeEnum data) => data.value; + + /// Decodes a [dynamic value][data] to a SyncAssetV1TypeEnum. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + SyncAssetV1TypeEnum? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'IMAGE': return SyncAssetV1TypeEnum.IMAGE; + case r'VIDEO': return SyncAssetV1TypeEnum.VIDEO; + case r'AUDIO': return SyncAssetV1TypeEnum.AUDIO; + case r'OTHER': return SyncAssetV1TypeEnum.OTHER; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [SyncAssetV1TypeEnumTypeTransformer] instance. + static SyncAssetV1TypeEnumTypeTransformer? _instance; +} + + diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart new file mode 100644 index 0000000000..5e52a10e7a --- /dev/null +++ b/mobile/openapi/lib/model/sync_entity_type.dart @@ -0,0 +1,109 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class SyncEntityType { + /// Instantiate a new enum with the provided [value]. + const SyncEntityType._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const userV1 = SyncEntityType._(r'UserV1'); + static const userDeleteV1 = SyncEntityType._(r'UserDeleteV1'); + static const partnerV1 = SyncEntityType._(r'PartnerV1'); + static const partnerDeleteV1 = SyncEntityType._(r'PartnerDeleteV1'); + static const assetV1 = SyncEntityType._(r'AssetV1'); + static const assetDeleteV1 = SyncEntityType._(r'AssetDeleteV1'); + static const assetExifV1 = SyncEntityType._(r'AssetExifV1'); + static const partnerAssetV1 = SyncEntityType._(r'PartnerAssetV1'); + static const partnerAssetDeleteV1 = SyncEntityType._(r'PartnerAssetDeleteV1'); + static const partnerAssetExifV1 = SyncEntityType._(r'PartnerAssetExifV1'); + + /// List of all possible values in this [enum][SyncEntityType]. + static const values = [ + userV1, + userDeleteV1, + partnerV1, + partnerDeleteV1, + assetV1, + assetDeleteV1, + assetExifV1, + partnerAssetV1, + partnerAssetDeleteV1, + partnerAssetExifV1, + ]; + + static SyncEntityType? fromJson(dynamic value) => SyncEntityTypeTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncEntityType.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [SyncEntityType] to String, +/// and [decode] dynamic data back to [SyncEntityType]. +class SyncEntityTypeTypeTransformer { + factory SyncEntityTypeTypeTransformer() => _instance ??= const SyncEntityTypeTypeTransformer._(); + + const SyncEntityTypeTypeTransformer._(); + + String encode(SyncEntityType data) => data.value; + + /// Decodes a [dynamic value][data] to a SyncEntityType. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + SyncEntityType? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'UserV1': return SyncEntityType.userV1; + case r'UserDeleteV1': return SyncEntityType.userDeleteV1; + case r'PartnerV1': return SyncEntityType.partnerV1; + case r'PartnerDeleteV1': return SyncEntityType.partnerDeleteV1; + case r'AssetV1': return SyncEntityType.assetV1; + case r'AssetDeleteV1': return SyncEntityType.assetDeleteV1; + case r'AssetExifV1': return SyncEntityType.assetExifV1; + case r'PartnerAssetV1': return SyncEntityType.partnerAssetV1; + case r'PartnerAssetDeleteV1': return SyncEntityType.partnerAssetDeleteV1; + case r'PartnerAssetExifV1': return SyncEntityType.partnerAssetExifV1; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [SyncEntityTypeTypeTransformer] instance. + static SyncEntityTypeTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/sync_partner_delete_v1.dart b/mobile/openapi/lib/model/sync_partner_delete_v1.dart new file mode 100644 index 0000000000..f5e10d6576 --- /dev/null +++ b/mobile/openapi/lib/model/sync_partner_delete_v1.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncPartnerDeleteV1 { + /// Returns a new [SyncPartnerDeleteV1] instance. + SyncPartnerDeleteV1({ + required this.sharedById, + required this.sharedWithId, + }); + + String sharedById; + + String sharedWithId; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncPartnerDeleteV1 && + other.sharedById == sharedById && + other.sharedWithId == sharedWithId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (sharedById.hashCode) + + (sharedWithId.hashCode); + + @override + String toString() => 'SyncPartnerDeleteV1[sharedById=$sharedById, sharedWithId=$sharedWithId]'; + + Map toJson() { + final json = {}; + json[r'sharedById'] = this.sharedById; + json[r'sharedWithId'] = this.sharedWithId; + return json; + } + + /// Returns a new [SyncPartnerDeleteV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncPartnerDeleteV1? fromJson(dynamic value) { + upgradeDto(value, "SyncPartnerDeleteV1"); + if (value is Map) { + final json = value.cast(); + + return SyncPartnerDeleteV1( + sharedById: mapValueOfType(json, r'sharedById')!, + sharedWithId: mapValueOfType(json, r'sharedWithId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncPartnerDeleteV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncPartnerDeleteV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncPartnerDeleteV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncPartnerDeleteV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'sharedById', + 'sharedWithId', + }; +} + diff --git a/mobile/openapi/lib/model/sync_partner_v1.dart b/mobile/openapi/lib/model/sync_partner_v1.dart new file mode 100644 index 0000000000..e551c4c83d --- /dev/null +++ b/mobile/openapi/lib/model/sync_partner_v1.dart @@ -0,0 +1,115 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncPartnerV1 { + /// Returns a new [SyncPartnerV1] instance. + SyncPartnerV1({ + required this.inTimeline, + required this.sharedById, + required this.sharedWithId, + }); + + bool inTimeline; + + String sharedById; + + String sharedWithId; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncPartnerV1 && + other.inTimeline == inTimeline && + other.sharedById == sharedById && + other.sharedWithId == sharedWithId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (inTimeline.hashCode) + + (sharedById.hashCode) + + (sharedWithId.hashCode); + + @override + String toString() => 'SyncPartnerV1[inTimeline=$inTimeline, sharedById=$sharedById, sharedWithId=$sharedWithId]'; + + Map toJson() { + final json = {}; + json[r'inTimeline'] = this.inTimeline; + json[r'sharedById'] = this.sharedById; + json[r'sharedWithId'] = this.sharedWithId; + return json; + } + + /// Returns a new [SyncPartnerV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncPartnerV1? fromJson(dynamic value) { + upgradeDto(value, "SyncPartnerV1"); + if (value is Map) { + final json = value.cast(); + + return SyncPartnerV1( + inTimeline: mapValueOfType(json, r'inTimeline')!, + sharedById: mapValueOfType(json, r'sharedById')!, + sharedWithId: mapValueOfType(json, r'sharedWithId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncPartnerV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncPartnerV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncPartnerV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncPartnerV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'inTimeline', + 'sharedById', + 'sharedWithId', + }; +} + diff --git a/mobile/openapi/lib/model/sync_request_type.dart b/mobile/openapi/lib/model/sync_request_type.dart new file mode 100644 index 0000000000..08f977ad57 --- /dev/null +++ b/mobile/openapi/lib/model/sync_request_type.dart @@ -0,0 +1,97 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class SyncRequestType { + /// Instantiate a new enum with the provided [value]. + const SyncRequestType._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const usersV1 = SyncRequestType._(r'UsersV1'); + static const partnersV1 = SyncRequestType._(r'PartnersV1'); + static const assetsV1 = SyncRequestType._(r'AssetsV1'); + static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1'); + static const partnerAssetsV1 = SyncRequestType._(r'PartnerAssetsV1'); + static const partnerAssetExifsV1 = SyncRequestType._(r'PartnerAssetExifsV1'); + + /// List of all possible values in this [enum][SyncRequestType]. + static const values = [ + usersV1, + partnersV1, + assetsV1, + assetExifsV1, + partnerAssetsV1, + partnerAssetExifsV1, + ]; + + static SyncRequestType? fromJson(dynamic value) => SyncRequestTypeTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncRequestType.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [SyncRequestType] to String, +/// and [decode] dynamic data back to [SyncRequestType]. +class SyncRequestTypeTypeTransformer { + factory SyncRequestTypeTypeTransformer() => _instance ??= const SyncRequestTypeTypeTransformer._(); + + const SyncRequestTypeTypeTransformer._(); + + String encode(SyncRequestType data) => data.value; + + /// Decodes a [dynamic value][data] to a SyncRequestType. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + SyncRequestType? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'UsersV1': return SyncRequestType.usersV1; + case r'PartnersV1': return SyncRequestType.partnersV1; + case r'AssetsV1': return SyncRequestType.assetsV1; + case r'AssetExifsV1': return SyncRequestType.assetExifsV1; + case r'PartnerAssetsV1': return SyncRequestType.partnerAssetsV1; + case r'PartnerAssetExifsV1': return SyncRequestType.partnerAssetExifsV1; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [SyncRequestTypeTypeTransformer] instance. + static SyncRequestTypeTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/sync_stream_dto.dart b/mobile/openapi/lib/model/sync_stream_dto.dart new file mode 100644 index 0000000000..28fd3dfaee --- /dev/null +++ b/mobile/openapi/lib/model/sync_stream_dto.dart @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncStreamDto { + /// Returns a new [SyncStreamDto] instance. + SyncStreamDto({ + this.types = const [], + }); + + List types; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncStreamDto && + _deepEquality.equals(other.types, types); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (types.hashCode); + + @override + String toString() => 'SyncStreamDto[types=$types]'; + + Map toJson() { + final json = {}; + json[r'types'] = this.types; + return json; + } + + /// Returns a new [SyncStreamDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncStreamDto? fromJson(dynamic value) { + upgradeDto(value, "SyncStreamDto"); + if (value is Map) { + final json = value.cast(); + + return SyncStreamDto( + types: SyncRequestType.listFromJson(json[r'types']), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncStreamDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncStreamDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncStreamDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncStreamDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'types', + }; +} + diff --git a/mobile/openapi/lib/model/sync_user_delete_v1.dart b/mobile/openapi/lib/model/sync_user_delete_v1.dart new file mode 100644 index 0000000000..09411cb79d --- /dev/null +++ b/mobile/openapi/lib/model/sync_user_delete_v1.dart @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncUserDeleteV1 { + /// Returns a new [SyncUserDeleteV1] instance. + SyncUserDeleteV1({ + required this.userId, + }); + + String userId; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncUserDeleteV1 && + other.userId == userId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (userId.hashCode); + + @override + String toString() => 'SyncUserDeleteV1[userId=$userId]'; + + Map toJson() { + final json = {}; + json[r'userId'] = this.userId; + return json; + } + + /// Returns a new [SyncUserDeleteV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncUserDeleteV1? fromJson(dynamic value) { + upgradeDto(value, "SyncUserDeleteV1"); + if (value is Map) { + final json = value.cast(); + + return SyncUserDeleteV1( + userId: mapValueOfType(json, r'userId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncUserDeleteV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncUserDeleteV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncUserDeleteV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncUserDeleteV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'userId', + }; +} + diff --git a/mobile/openapi/lib/model/sync_user_v1.dart b/mobile/openapi/lib/model/sync_user_v1.dart new file mode 100644 index 0000000000..b9b41bb723 --- /dev/null +++ b/mobile/openapi/lib/model/sync_user_v1.dart @@ -0,0 +1,127 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncUserV1 { + /// Returns a new [SyncUserV1] instance. + SyncUserV1({ + required this.deletedAt, + required this.email, + required this.id, + required this.name, + }); + + DateTime? deletedAt; + + String email; + + String id; + + String name; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncUserV1 && + other.deletedAt == deletedAt && + other.email == email && + other.id == id && + other.name == name; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (deletedAt == null ? 0 : deletedAt!.hashCode) + + (email.hashCode) + + (id.hashCode) + + (name.hashCode); + + @override + String toString() => 'SyncUserV1[deletedAt=$deletedAt, email=$email, id=$id, name=$name]'; + + Map toJson() { + final json = {}; + if (this.deletedAt != null) { + json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String(); + } else { + // json[r'deletedAt'] = null; + } + json[r'email'] = this.email; + json[r'id'] = this.id; + json[r'name'] = this.name; + return json; + } + + /// Returns a new [SyncUserV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncUserV1? fromJson(dynamic value) { + upgradeDto(value, "SyncUserV1"); + if (value is Map) { + final json = value.cast(); + + return SyncUserV1( + deletedAt: mapDateTime(json, r'deletedAt', r''), + email: mapValueOfType(json, r'email')!, + id: mapValueOfType(json, r'id')!, + name: mapValueOfType(json, r'name')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncUserV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncUserV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncUserV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncUserV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'deletedAt', + 'email', + 'id', + 'name', + }; +} + diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index 9ebce5fd92..c6ae6d8e07 100644 --- a/mobile/openapi/lib/model/update_asset_dto.dart +++ b/mobile/openapi/lib/model/update_asset_dto.dart @@ -73,7 +73,7 @@ class UpdateAssetDto { /// num? longitude; - /// Minimum value: 0 + /// Minimum value: -1 /// Maximum value: 5 /// /// Please note: This property should have been non-nullable! Since the specification file diff --git a/mobile/openapi/lib/model/user_preferences_response_dto.dart b/mobile/openapi/lib/model/user_preferences_response_dto.dart index 23d9ea84ec..b244284eb0 100644 --- a/mobile/openapi/lib/model/user_preferences_response_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_response_dto.dart @@ -21,6 +21,7 @@ class UserPreferencesResponseDto { required this.people, required this.purchase, required this.ratings, + required this.sharedLinks, required this.tags, }); @@ -40,6 +41,8 @@ class UserPreferencesResponseDto { RatingsResponse ratings; + SharedLinksResponse sharedLinks; + TagsResponse tags; @override @@ -52,6 +55,7 @@ class UserPreferencesResponseDto { other.people == people && other.purchase == purchase && other.ratings == ratings && + other.sharedLinks == sharedLinks && other.tags == tags; @override @@ -65,10 +69,11 @@ class UserPreferencesResponseDto { (people.hashCode) + (purchase.hashCode) + (ratings.hashCode) + + (sharedLinks.hashCode) + (tags.hashCode); @override - String toString() => 'UserPreferencesResponseDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, tags=$tags]'; + String toString() => 'UserPreferencesResponseDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]'; Map toJson() { final json = {}; @@ -80,6 +85,7 @@ class UserPreferencesResponseDto { json[r'people'] = this.people; json[r'purchase'] = this.purchase; json[r'ratings'] = this.ratings; + json[r'sharedLinks'] = this.sharedLinks; json[r'tags'] = this.tags; return json; } @@ -101,6 +107,7 @@ class UserPreferencesResponseDto { people: PeopleResponse.fromJson(json[r'people'])!, purchase: PurchaseResponse.fromJson(json[r'purchase'])!, ratings: RatingsResponse.fromJson(json[r'ratings'])!, + sharedLinks: SharedLinksResponse.fromJson(json[r'sharedLinks'])!, tags: TagsResponse.fromJson(json[r'tags'])!, ); } @@ -157,6 +164,7 @@ class UserPreferencesResponseDto { 'people', 'purchase', 'ratings', + 'sharedLinks', 'tags', }; } diff --git a/mobile/openapi/lib/model/user_preferences_update_dto.dart b/mobile/openapi/lib/model/user_preferences_update_dto.dart index 208dbf6860..3e420df119 100644 --- a/mobile/openapi/lib/model/user_preferences_update_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_update_dto.dart @@ -21,6 +21,7 @@ class UserPreferencesUpdateDto { this.people, this.purchase, this.ratings, + this.sharedLinks, this.tags, }); @@ -88,6 +89,14 @@ class UserPreferencesUpdateDto { /// RatingsUpdate? ratings; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + SharedLinksUpdate? sharedLinks; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -106,6 +115,7 @@ class UserPreferencesUpdateDto { other.people == people && other.purchase == purchase && other.ratings == ratings && + other.sharedLinks == sharedLinks && other.tags == tags; @override @@ -119,10 +129,11 @@ class UserPreferencesUpdateDto { (people == null ? 0 : people!.hashCode) + (purchase == null ? 0 : purchase!.hashCode) + (ratings == null ? 0 : ratings!.hashCode) + + (sharedLinks == null ? 0 : sharedLinks!.hashCode) + (tags == null ? 0 : tags!.hashCode); @override - String toString() => 'UserPreferencesUpdateDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, tags=$tags]'; + String toString() => 'UserPreferencesUpdateDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]'; Map toJson() { final json = {}; @@ -166,6 +177,11 @@ class UserPreferencesUpdateDto { } else { // json[r'ratings'] = null; } + if (this.sharedLinks != null) { + json[r'sharedLinks'] = this.sharedLinks; + } else { + // json[r'sharedLinks'] = null; + } if (this.tags != null) { json[r'tags'] = this.tags; } else { @@ -191,6 +207,7 @@ class UserPreferencesUpdateDto { people: PeopleUpdate.fromJson(json[r'people']), purchase: PurchaseUpdate.fromJson(json[r'purchase']), ratings: RatingsUpdate.fromJson(json[r'ratings']), + sharedLinks: SharedLinksUpdate.fromJson(json[r'sharedLinks']), tags: TagsUpdate.fromJson(json[r'tags']), ); } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 5a15bf5f5e..58a4f87847 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -5,23 +5,23 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834 + sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" url: "https://pub.dev" source: hosted - version: "72.0.0" + version: "76.0.0" _macros: dependency: transitive description: dart source: sdk - version: "0.3.2" + version: "0.3.3" analyzer: dependency: "direct overridden" description: name: analyzer - sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139 + sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" url: "https://pub.dev" source: hosted - version: "6.7.0" + version: "6.11.0" analyzer_plugin: dependency: "direct overridden" description: @@ -34,42 +34,42 @@ packages: dependency: transitive description: name: ansicolor - sha256: "8bf17a8ff6ea17499e40a2d2542c2f481cd7615760c6d34065cb22bfd22e6880" + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.0.3" archive: dependency: transitive description: name: archive - sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d + sha256: "0c64e928dcbefddecd234205422bcfc2b5e6d31be0b86fef0d0dd48d7b4c9742" url: "https://pub.dev" source: hosted - version: "3.6.1" + version: "4.0.4" args: dependency: transitive description: name: args - sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.6.0" async: dependency: "direct main" description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.12.0" auto_route: dependency: "direct main" description: name: auto_route - sha256: b83e8ce46da7228cdd019b5a11205454847f0a971bca59a7529b98df9876889b + sha256: "1d1bd908a1fec327719326d5d0791edd37f16caff6493c01003689fb03315ad7" url: "https://pub.dev" source: hosted - version: "9.2.2" + version: "9.3.0+1" auto_route_generator: dependency: "direct dev" description: @@ -82,66 +82,66 @@ packages: dependency: "direct main" description: name: background_downloader - sha256: "6a945db1a1c7727a4bc9c1d7c882cfb1a819f873b77e01d5e5dd6a3fb231cb28" + sha256: ed64a215cd24c83a478f602364a3ca86a6dafd178ad783188cc32c6956d5e529 url: "https://pub.dev" source: hosted - version: "8.5.5" + version: "8.9.4" boolean_selector: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" build: dependency: transitive description: name: build - sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" build_config: dependency: transitive description: name: build_config - sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" build_daemon: dependency: transitive description: name: build_daemon - sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9" + sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.0.4" build_resolvers: dependency: transitive description: name: build_resolvers - sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" + sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.4" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "644dc98a0f179b872f612d3eb627924b578897c629788e858157fa5e704ca0c7" + sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" url: "https://pub.dev" source: hosted - version: "2.4.11" + version: "2.4.15" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: e3c79f69a64bdfcd8a776a3c28db4eb6e3fb5356d013ae5eb2e52007706d5dbe + sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" url: "https://pub.dev" source: hosted - version: "7.3.1" + version: "8.0.0" built_collection: dependency: transitive description: @@ -154,34 +154,34 @@ packages: dependency: transitive description: name: built_value - sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb + sha256: ea90e81dc4a25a043d9bee692d20ed6d1c4a1662a28c03a96417446c093ed6b4 url: "https://pub.dev" source: hosted - version: "8.9.2" + version: "8.9.5" cached_network_image: dependency: "direct main" description: name: cached_network_image - sha256: "28ea9690a8207179c319965c13cd8df184d5ee721ae2ce60f398ced1219cea1f" + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" url: "https://pub.dev" source: hosted - version: "3.3.1" + version: "3.4.1" cached_network_image_platform_interface: dependency: transitive description: name: cached_network_image_platform_interface - sha256: "9e90e78ae72caa874a323d78fa6301b3fb8fa7ea76a8f96dc5b5bf79f283bf2f" + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.1.1" cached_network_image_web: dependency: transitive description: name: cached_network_image_web - sha256: "205d6a9f1862de34b93184f22b9d2d94586b2f05c581d546695e3d8f6a805cd7" + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.1" cancellation_token: dependency: transitive description: @@ -194,18 +194,18 @@ packages: dependency: "direct main" description: name: cancellation_token_http - sha256: "37ad2a20dba02aeb1f0a4d845e7a57eebacdb709e1186e0491e7cd81c559c4ff" + sha256: "0fff478fe5153700396b3472ddf93303c219f1cb8d8e779e65b014cb9c7f0213" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" characters: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -226,42 +226,42 @@ packages: dependency: transitive description: name: cli_util - sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19 + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c url: "https://pub.dev" source: hosted - version: "0.4.1" + version: "0.4.2" clock: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" code_builder: dependency: transitive description: name: code_builder - sha256: f692079e25e7869c14132d39f223f8eec9830eb76131925143b2129c4bb01b37 + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" url: "https://pub.dev" source: hosted - version: "4.10.0" + version: "4.10.1" collection: dependency: "direct main" description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.1" connectivity_plus: dependency: "direct main" description: name: connectivity_plus - sha256: "2056db5241f96cdc0126bd94459fc4cdc13876753768fc7a31c425e50a7177d0" + sha256: "04bf81bb0b77de31557b58d052b24b3eee33f09a6e7a8c68a3e247c7df19ec27" url: "https://pub.dev" source: hosted - version: "6.0.5" + version: "6.1.3" connectivity_plus_platform_interface: dependency: transitive description: @@ -274,18 +274,18 @@ packages: dependency: transitive description: name: convert - sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.2" crop_image: dependency: "direct main" description: name: crop_image - sha256: "6cf20655ecbfba99c369d43ec7adcfa49bf135af88fb75642173d6224a95d3f1" + sha256: "4fdebd00d0c7d1a6e3abeb1e3843efbc202204b867f3e377fcebcf77aaf69a17" url: "https://pub.dev" source: hosted - version: "1.0.13" + version: "1.0.16" cross_file: dependency: transitive description: @@ -298,50 +298,50 @@ packages: dependency: transitive description: name: crypto - sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.6" csslib: dependency: transitive description: name: csslib - sha256: "706b5707578e0c1b4b7550f64078f0a0f19dec3f50a178ffae7006b0a9ca58fb" + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.2" custom_lint: dependency: "direct dev" description: name: custom_lint - sha256: "7c0aec12df22f9082146c354692056677f1e70bc43471644d1fdb36c6fdda799" + sha256: "4500e88854e7581ee43586abeaf4443cb22375d6d289241a87b1aadf678d5545" url: "https://pub.dev" source: hosted - version: "0.6.4" + version: "0.6.10" custom_lint_builder: dependency: transitive description: name: custom_lint_builder - sha256: d7dc41e709dde223806660268678be7993559e523eb3164e2a1425fd6f7615a9 + sha256: "5a95eff100da256fbf086b329c17c8b49058c261cdf56d3a4157d3c31c511d78" url: "https://pub.dev" source: hosted - version: "0.6.4" + version: "0.6.10" custom_lint_core: dependency: transitive description: name: custom_lint_core - sha256: a85e8f78f4c52f6c63cdaf8c872eb573db0231dcdf3c3a5906d493c1f8bc20e6 + sha256: "76a4046cc71d976222a078a8fd4a65e198b70545a8d690a75196dd14f08510f6" url: "https://pub.dev" source: hosted - version: "0.6.3" + version: "0.6.10" dart_style: dependency: transitive description: name: dart_style - sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" + sha256: "7306ab8a2359a48d22310ad823521d723acfed60ee1f7e37388e8986853b6820" url: "https://pub.dev" source: hosted - version: "2.3.6" + version: "2.3.8" dartx: dependency: transitive description: @@ -354,26 +354,34 @@ packages: dependency: transitive description: name: dbus - sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" + desktop_webview_window: + dependency: transitive + description: + name: desktop_webview_window + sha256: "57cf20d81689d5cbb1adfd0017e96b669398a669d927906073b0e42fc64111c0" + url: "https://pub.dev" + source: hosted + version: "0.2.3" device_info_plus: dependency: "direct main" description: name: device_info_plus - sha256: f545ffbadee826f26f2e1a0f0cbd667ae9a6011cc0f77c0f8f00a969655e6e95 + sha256: "306b78788d1bb569edb7c55d622953c2414ca12445b41c9117963e03afc5c513" url: "https://pub.dev" source: hosted - version: "11.1.1" + version: "11.3.3" device_info_plus_platform_interface: dependency: transitive description: name: device_info_plus_platform_interface - sha256: "282d3cf731045a2feb66abfe61bbc40870ae50a3ed10a4d3d217556c35c8c2ba" + sha256: "0b04e02b30791224b31969eb1b50d723498f402971bff3630bca2ba839bd1ed2" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.0.2" dynamic_color: dependency: "direct main" description: @@ -394,10 +402,10 @@ packages: dependency: "direct main" description: name: easy_localization - sha256: fa59bcdbbb911a764aa6acf96bbb6fa7a5cf8234354fc45ec1a43a0349ef0201 + sha256: "0f5239c7b8ab06c66440cfb0e9aa4b4640429c6668d5a42fe389c5de42220b12" url: "https://pub.dev" source: hosted - version: "3.0.7" + version: "3.0.7+1" easy_logger: dependency: transitive description: @@ -407,53 +415,53 @@ packages: source: hosted version: "0.0.2" fake_async: - dependency: transitive + dependency: "direct dev" description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.2" ffi: dependency: transitive description: name: ffi - sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21" + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" file: dependency: transitive description: name: file - sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.0.1" file_picker: dependency: "direct main" description: name: file_picker - sha256: aac85f20436608e01a6ffd1fdd4e746a7f33c93a2c83752e626bdfaea139b877 + sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 url: "https://pub.dev" source: hosted - version: "8.1.3" + version: "8.3.7" file_selector_linux: dependency: transitive description: name: file_selector_linux - sha256: "045d372bf19b02aeb69cacf8b4009555fb5f6f0b7ad8016e5f46dd1387ddd492" + sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33" url: "https://pub.dev" source: hosted - version: "0.9.2+1" + version: "0.9.3+2" file_selector_macos: dependency: transitive description: name: file_selector_macos - sha256: f42eacb83b318e183b1ae24eead1373ab1334084404c8c16e0354f9a3e55d385 + sha256: "271ab9986df0c135d45c3cdb6bd0faa5db6f4976d3e4b437cf7d0f258d941bfc" url: "https://pub.dev" source: hosted - version: "0.9.4" + version: "0.9.4+2" file_selector_platform_interface: dependency: transitive description: @@ -466,18 +474,18 @@ packages: dependency: transitive description: name: file_selector_windows - sha256: "2ad726953f6e8affbc4df8dc78b77c3b4a060967a291e528ef72ae846c60fb69" + sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b" url: "https://pub.dev" source: hosted - version: "0.9.3+2" + version: "0.9.3+4" fixnum: dependency: transitive description: name: fixnum - sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -487,10 +495,10 @@ packages: dependency: "direct main" description: name: flutter_cache_manager - sha256: "8207f27539deb83732fdda03e259349046a39a4c767269285f449ade355d54ba" + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" url: "https://pub.dev" source: hosted - version: "3.3.1" + version: "3.4.1" flutter_displaymode: dependency: "direct main" description: @@ -508,18 +516,18 @@ packages: dependency: "direct main" description: name: flutter_hooks - sha256: cde36b12f7188c85286fba9b38cc5a902e7279f36dd676967106c041dc9dde70 + sha256: b772e710d16d7a20c0740c4f855095026b31c7eb5ba3ab67d2bd52021cd9461d url: "https://pub.dev" source: hosted - version: "0.20.5" + version: "0.21.2" flutter_launcher_icons: dependency: "direct dev" description: name: flutter_launcher_icons - sha256: "619817c4b65b322b5104b6bb6dfe6cda62d9729bd7ad4303ecc8b4e690a67a77" + sha256: bfa04787c85d80ecb3f8777bde5fc10c3de809240c48fa061a2c2bf15ea5211c url: "https://pub.dev" source: hosted - version: "0.14.1" + version: "0.14.3" flutter_lints: dependency: "direct dev" description: @@ -532,10 +540,10 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: dd6676d8c2926537eccdf9f72128bbb2a9d0814689527b17f92c248ff192eaf3 + sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35" url: "https://pub.dev" source: hosted - version: "17.2.1+2" + version: "17.2.4" flutter_local_notifications_linux: dependency: transitive description: @@ -561,34 +569,34 @@ packages: dependency: "direct dev" description: name: flutter_native_splash - sha256: aa06fec78de2190f3db4319dd60fdc8d12b2626e93ef9828633928c2dcaea840 + sha256: edb09c35ee9230c4b03f13dd45bb3a276d0801865f0a4650b7e2a3bba61a803a url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.5" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "9d98bd47ef9d34e803d438f17fd32b116d31009f534a6fa5ce3a1167f189a6de" + sha256: "5a1e6fb2c0561958d7e4c33574674bda7b77caaca7a33b758876956f2902eea3" url: "https://pub.dev" source: hosted - version: "2.0.21" + version: "2.0.27" flutter_riverpod: dependency: transitive description: name: flutter_riverpod - sha256: "0f1974eff5bbe774bf1d870e406fc6f29e3d6f1c46bd9c58e7172ff68a785d7d" + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "2.6.1" flutter_svg: dependency: "direct main" description: name: flutter_svg - sha256: d39e7f95621fc84376bc0f7d504f05c3a41488c562f4a8ad410569127507402c + sha256: c200fd79c918a40c5cd50ea0877fa13f81bdaf6f0a5d3dbcc2a13e3285d6aa1b url: "https://pub.dev" source: hosted - version: "2.0.9" + version: "2.0.17" flutter_test: dependency: "direct dev" description: flutter @@ -598,18 +606,26 @@ packages: dependency: "direct main" description: name: flutter_udid - sha256: "63384bd96203aaefccfd7137fab642edda18afede12b0e9e1a2c96fe2589fd07" + sha256: be464dc5b1fb7ee894f6a32d65c086ca5e177fdcf9375ac08d77495b98150f84 url: "https://pub.dev" source: hosted - version: "3.0.0" - flutter_web_auth: + version: "3.0.1" + flutter_web_auth_2: dependency: "direct main" description: - name: flutter_web_auth - sha256: "95e4856e24fb6ac1678f5ff334743b63f782d839ab324543d29ccbd295176209" + name: flutter_web_auth_2 + sha256: "561c32d32ed537853de43852c35849cf1d37f3482f41f22b718ab6112f96b333" url: "https://pub.dev" source: hosted - version: "0.6.0" + version: "5.0.0-alpha.0" + flutter_web_auth_2_platform_interface: + dependency: transitive + description: + name: flutter_web_auth_2_platform_interface + sha256: "45927587ebb2364cd273675ec95f6f67b81725754b416cef2b65cdc63fd3e853" + url: "https://pub.dev" + source: hosted + version: "5.0.0-alpha.0" flutter_web_plugins: dependency: transitive description: flutter @@ -619,10 +635,10 @@ packages: dependency: "direct main" description: name: fluttertoast - sha256: "95f349437aeebe524ef7d6c9bde3e6b4772717cf46a0eb6a3ceaddc740b297cc" + sha256: "25e51620424d92d3db3832464774a6143b5053f15e382d8ffbfd40b6e795dcf1" url: "https://pub.dev" source: hosted - version: "8.2.8" + version: "8.2.12" freezed_annotation: dependency: transitive description: @@ -664,10 +680,10 @@ packages: dependency: transitive description: name: geolocator_apple - sha256: bc2aca02423ad429cb0556121f56e60360a2b7d694c8570301d06ea0c00732fd + sha256: c4ecead17985ede9634f21500072edfcb3dba0ef7b97f8d7bc556d2d722b3ba3 url: "https://pub.dev" source: hosted - version: "2.3.7" + version: "2.3.9" geolocator_platform_interface: dependency: transitive description: @@ -696,10 +712,10 @@ packages: dependency: transitive description: name: glob - sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" graphs: dependency: transitive description: @@ -712,58 +728,58 @@ packages: dependency: "direct main" description: name: hooks_riverpod - sha256: "45b2030a18bcd6dbd680c2c91bc3b33e3fe7c323e3acb5ecec93a613e2fbaa8a" + sha256: "70bba33cfc5670c84b796e6929c54b8bc5be7d0fe15bb28c2560500b9ad06966" url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "2.6.1" hotreloader: dependency: transitive description: name: hotreloader - sha256: ed56fdc1f3a8ac924e717257621d09e9ec20e308ab6352a73a50a1d7a4d9158e + sha256: bc167a1163807b03bada490bfe2df25b0d744df359227880220a5cbd04e5734b url: "https://pub.dev" source: hosted - version: "4.2.0" + version: "4.3.0" html: dependency: transitive description: name: html - sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" + sha256: "1fc58edeaec4307368c60d59b7e15b9d658b57d7f3125098b6294153c75337ec" url: "https://pub.dev" source: hosted - version: "0.15.4" + version: "0.15.5" http: dependency: "direct main" description: name: http - sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 + sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "1.3.0" http_multi_server: dependency: transitive description: name: http_multi_server - sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" http_parser: dependency: "direct main" description: name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.1.2" image: dependency: transitive description: name: image - sha256: "2237616a36c0d69aef7549ab439b833fb7f9fb9fc861af2cc9ac3eedddd69ca8" + sha256: "13d3349ace88f12f4a0d175eb5c12dcdd39d35c4c109a8a13dfeb6d0bd9e31c3" url: "https://pub.dev" source: hosted - version: "4.2.0" + version: "4.5.3" image_picker: dependency: "direct main" description: @@ -776,26 +792,26 @@ packages: dependency: transitive description: name: image_picker_android - sha256: c0e72ecd170b00a5590bb71238d57dc8ad22ee14c60c6b0d1a4e05cafbc5db4b + sha256: "8bd392ba8b0c8957a157ae0dc9fcf48c58e6c20908d5880aea1d79734df090e9" url: "https://pub.dev" source: hosted - version: "0.8.12+11" + version: "0.8.12+22" image_picker_for_web: dependency: transitive description: name: image_picker_for_web - sha256: "65d94623e15372c5c51bebbcb820848d7bcb323836e12dfdba60b5d3a8b39e50" + sha256: "717eb042ab08c40767684327be06a5d8dbb341fe791d514e4b92c7bbe1b7bb83" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.6" image_picker_ios: dependency: transitive description: name: image_picker_ios - sha256: "6703696ad49f5c3c8356d576d7ace84d1faf459afb07accbb0fae780753ff447" + sha256: "05da758e67bc7839e886b3959848aa6b44ff123ab4b28f67891008afe8ef9100" url: "https://pub.dev" source: hosted - version: "0.8.12" + version: "0.8.12+2" image_picker_linux: dependency: transitive description: @@ -808,18 +824,18 @@ packages: dependency: transitive description: name: image_picker_macos - sha256: "3f5ad1e8112a9a6111c46d0b57a7be2286a9a07fc6e1976fdf5be2bd31d4ff62" + sha256: "1b90ebbd9dcf98fb6c1d01427e49a55bd96b5d67b8c67cf955d60a5de74207c1" url: "https://pub.dev" source: hosted - version: "0.2.1+1" + version: "0.2.1+2" image_picker_platform_interface: dependency: transitive description: name: image_picker_platform_interface - sha256: "9ec26d410ff46f483c5519c29c02ef0e02e13a543f882b152d4bfd2f06802f80" + sha256: "886d57f0be73c4b140004e78b9f28a8914a09e50c2d816bdd0520051a71236a0" url: "https://pub.dev" source: hosted - version: "2.10.0" + version: "2.10.1" image_picker_windows: dependency: transitive description: @@ -852,10 +868,10 @@ packages: dependency: transitive description: name: io - sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.0.5" isar: dependency: "direct main" description: @@ -900,18 +916,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec url: "https://pub.dev" source: hosted - version: "10.0.5" + version: "10.0.8" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.9" leak_tracker_testing: dependency: transitive description: @@ -924,58 +940,58 @@ packages: dependency: transitive description: name: lints - sha256: "3315600f3fb3b135be672bf4a178c55f274bebe368325ae18462c89ac1e3b413" + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "5.1.1" logging: dependency: "direct main" description: name: logging - sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" macros: dependency: transitive description: name: macros - sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" + sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" url: "https://pub.dev" source: hosted - version: "0.1.2-main.4" + version: "0.1.3-main.0" maplibre_gl: dependency: "direct main" description: name: maplibre_gl - sha256: "9dd9eebee52f42a45aaa9cdb912afa47845c37007b26a799aa482ecd368804c8" + sha256: cd0adf2da87149cab556ac70977783d6dcb3bd73b17a5583cc8366a5aafa46f8 url: "https://pub.dev" source: hosted - version: "0.19.0+2" + version: "0.21.0" maplibre_gl_platform_interface: dependency: transitive description: name: maplibre_gl_platform_interface - sha256: a95fa38a3532253f32dfe181389adfe9f402773e58ac902d9c4efad3209e0903 + sha256: "6db8234705e58c09b6fd5a43747a817ba1e6e91a76deb3ed057a36a994d86f22" url: "https://pub.dev" source: hosted - version: "0.19.0+2" + version: "0.21.0" maplibre_gl_web: dependency: transitive description: name: maplibre_gl_web - sha256: "7f1540b384f16f3c9bc8b4ebdfca96fb07f6dab5d9ef4dd0e102985dba238691" + sha256: e1cbe04594fdb0d76de7cd448c0048290df8dc69dc37a85d23307dd595779141 url: "https://pub.dev" source: hosted - version: "0.19.0+2" + version: "0.21.0" matcher: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.17" material_color_utilities: dependency: transitive description: @@ -988,18 +1004,18 @@ packages: dependency: "direct overridden" description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.16.0" mime: dependency: transitive description: name: mime - sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "2.0.0" mocktail: dependency: "direct dev" description: @@ -1012,8 +1028,8 @@ packages: dependency: "direct main" description: path: "." - ref: "4530808" - resolved-ref: "4530808a6d04c9992de184c423c9e87fbf6a53eb" + ref: "5459d54" + resolved-ref: "5459d54cdc1cf4d99e2193b310052f1ebb5dcf43" url: "https://github.com/immich-app/native_video_player" source: git version: "1.3.1" @@ -1021,18 +1037,18 @@ packages: dependency: "direct main" description: name: network_info_plus - sha256: bf9e39e523e9951d741868dc33ac386b0bc24301e9b7c8a7d60dbc34879150a8 + sha256: "08f4166bbb77da9e407edef6322a33f87b18c0ca46483fb25606cb3d2bfcdd2a" url: "https://pub.dev" source: hosted - version: "6.1.1" + version: "6.1.3" network_info_plus_platform_interface: dependency: transitive description: name: network_info_plus_platform_interface - sha256: b7f35f4a7baef511159e524499f3c15464a49faa5ec10e92ee0bce265e664906 + sha256: "7e7496a8a9d8136859b8881affc613c4a21304afeb6c324bcefc4bd0aff6b94b" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.2" nm: dependency: transitive description: @@ -1060,74 +1076,66 @@ packages: dependency: transitive description: name: package_config - sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" package_info_plus: dependency: "direct main" description: name: package_info_plus - sha256: da8d9ac8c4b1df253d1a328b7bf01ae77ef132833479ab40763334db13b91cce + sha256: "7976bfe4c583170d6cdc7077e3237560b364149fcd268b5f53d95a991963b191" url: "https://pub.dev" source: hosted - version: "8.1.1" + version: "8.3.0" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface - sha256: ac1f4a4847f1ade8e6a87d1f39f5d7c67490738642e2542f559ec38c37489a66 + sha256: "6c935fb612dff8e3cc9632c2b301720c77450a126114126ffaafe28d2e87956c" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.2.0" path: dependency: "direct main" description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" path_parsing: dependency: transitive description: name: path_parsing - sha256: e3e67b1629e6f7e8100b367d3db6ba6af4b1f0bb80f64db18ef1fbabd2fa9ccf + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.1.0" path_provider: dependency: "direct main" description: name: path_provider - sha256: fec0d61223fba3154d87759e3cc27fe2c8dc498f6386c6d6fc80d1afdd1bf378 + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.1.5" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: "490539678396d4c3c0b06efdaab75ae60675c3e0c66f72bc04c2e2c1e0e2abeb" + sha256: "0ca7359dad67fd7063cb2892ab0c0737b2daafd807cf1acecd62374c8fae6c12" url: "https://pub.dev" source: hosted - version: "2.2.9" + version: "2.2.16" path_provider_foundation: - dependency: transitive - description: - name: path_provider_foundation - sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 - url: "https://pub.dev" - source: hosted - version: "2.4.0" - path_provider_ios: dependency: "direct main" description: - name: path_provider_ios - sha256: "03d639406f5343478352433f00d3c4394d52dac8df3d847869c5e2333e0bbce8" + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" url: "https://pub.dev" source: hosted - version: "2.0.11" + version: "2.4.1" path_provider_linux: dependency: transitive description: @@ -1156,42 +1164,42 @@ packages: dependency: "direct main" description: name: permission_handler - sha256: "18bf33f7fefbd812f37e72091a15575e72d5318854877e0e4035a24ac1113ecb" + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" url: "https://pub.dev" source: hosted - version: "11.3.1" + version: "11.4.0" permission_handler_android: dependency: transitive description: name: permission_handler_android - sha256: eaf2a1ec4472775451e88ca6a7b86559ef2f1d1ed903942ed135e38ea0097dca + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc url: "https://pub.dev" source: hosted - version: "12.0.8" + version: "12.1.0" permission_handler_apple: dependency: transitive description: name: permission_handler_apple - sha256: e6f6d73b12438ef13e648c4ae56bd106ec60d17e90a59c4545db6781229082a0 + sha256: f84a188e79a35c687c132a0a0556c254747a08561e99ab933f12f6ca71ef3c98 url: "https://pub.dev" source: hosted - version: "9.4.5" + version: "9.4.6" permission_handler_html: dependency: transitive description: name: permission_handler_html - sha256: af26edbbb1f2674af65a8f4b56e1a6f526156bc273d0e65dd8075fab51c78851 + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" url: "https://pub.dev" source: hosted - version: "0.1.3+2" + version: "0.1.3+5" permission_handler_platform_interface: dependency: transitive description: name: permission_handler_platform_interface - sha256: fe0ffe274d665be8e34f9c59705441a7d248edebbe5d9e3ec2665f88b79358ea + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 url: "https://pub.dev" source: hosted - version: "4.2.2" + version: "4.3.0" permission_handler_windows: dependency: transitive description: @@ -1204,18 +1212,18 @@ packages: dependency: transitive description: name: petitparser - sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" url: "https://pub.dev" source: hosted - version: "6.0.2" + version: "6.1.0" photo_manager: dependency: "direct main" description: name: photo_manager - sha256: f5ef2618870e9a50d8bfeb81a02c242d580ae8614bd5ea9e1b80dbb7e49d4260 + sha256: "0bc7548fd3111eb93a3b0abf1c57364e40aeda32512c100085a48dade60e574f" url: "https://pub.dev" source: hosted - version: "3.6.1" + version: "3.6.4" photo_manager_image_provider: dependency: "direct main" description: @@ -1228,10 +1236,10 @@ packages: dependency: transitive description: name: platform - sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" url: "https://pub.dev" source: hosted - version: "3.1.5" + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -1248,78 +1256,86 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" + posix: + dependency: transitive + description: + name: posix + sha256: a0117dc2167805aa9125b82eee515cc891819bac2f538c83646d355b16f58b9a + url: "https://pub.dev" + source: hosted + version: "6.0.1" process: dependency: transitive description: name: process - sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32" + sha256: "107d8be718f120bbba9dcd1e95e3bd325b1b4a4f07db64154635ba03f2567a0d" url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "5.0.3" pub_semver: dependency: transitive description: name: pub_semver - sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8 + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.5.0" riverpod: dependency: transitive description: name: riverpod - sha256: f21b32ffd26a36555e501b04f4a5dca43ed59e16343f1a30c13632b2351dfa4d + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "2.6.1" riverpod_analyzer_utils: dependency: transitive description: name: riverpod_analyzer_utils - sha256: ee72770090078e6841d51355292335f1bc254907c6694283389dcb8156d99a4d + sha256: "0dcb0af32d561f8fa000c6a6d95633c9fb08ea8a8df46e3f9daca59f11218167" url: "https://pub.dev" source: hosted - version: "0.5.3" + version: "0.5.6" riverpod_annotation: dependency: "direct main" description: name: riverpod_annotation - sha256: e5e796c0eba4030c704e9dae1b834a6541814963292839dcf9638d53eba84f5c + sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8 url: "https://pub.dev" source: hosted - version: "2.3.5" + version: "2.6.1" riverpod_generator: dependency: "direct dev" description: name: riverpod_generator - sha256: "1ad626afbd8b01d168870b13c0b036f8a5bdb57c14cd426dc5b4595466bd6e2f" + sha256: "851aedac7ad52693d12af3bf6d92b1626d516ed6b764eb61bf19e968b5e0b931" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.6.1" riverpod_lint: dependency: "direct dev" description: name: riverpod_lint - sha256: b95a8cdc6102397f7d51037131c25ce7e51be900be021af4bf0c2d6f1b8f7aa7 + sha256: "0684c21a9a4582c28c897d55c7b611fa59a351579061b43f8c92c005804e63a8" url: "https://pub.dev" source: hosted - version: "2.3.12" + version: "2.6.1" rxdart: dependency: transitive description: name: rxdart - sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" url: "https://pub.dev" source: hosted - version: "0.27.7" + version: "0.28.0" scrollable_positioned_list: dependency: "direct main" description: @@ -1364,50 +1380,50 @@ packages: dependency: "direct main" description: name: share_plus - sha256: "9c9bafd4060728d7cdb2464c341743adbd79d327cb067ec7afb64583540b47c8" + sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da url: "https://pub.dev" source: hosted - version: "10.1.2" + version: "10.1.4" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface - sha256: c57c0bbfec7142e3a0f55633be504b796af72e60e3c791b44d5a017b985f7a48 + sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b url: "https://pub.dev" source: hosted - version: "5.0.1" + version: "5.0.2" shared_preferences: dependency: transitive description: name: shared_preferences - sha256: c272f9cabca5a81adc9b0894381e9c1def363e980f960fa903c604c471b22f68 + sha256: "846849e3e9b68f3ef4b60c60cf4b3e02e9321bc7f4d8c4692cf87ffa82fc8a3a" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.5.2" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "041be4d9d2dc6079cf342bc8b761b03787e3b71192d658220a56cac9c04a0294" + sha256: "3ec7210872c4ba945e3244982918e502fa2bfb5230dff6832459ca0e1879b7ad" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.4.8" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "671e7a931f55a08aa45be2a13fe7247f2a41237897df434b30d2012388191833" + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.5.4" shared_preferences_linux: dependency: transitive description: name: shared_preferences_linux - sha256: "2ba0510d3017f91655b7543e9ee46d48619de2a2af38e5c790423f7007c7ccc1" + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" shared_preferences_platform_interface: dependency: transitive description: @@ -1420,39 +1436,39 @@ packages: dependency: transitive description: name: shared_preferences_web - sha256: "59dc807b94d29d52ddbb1b3c0d3b9d0a67fc535a64e62a5542c8db0513fcb6c2" + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.3" shared_preferences_windows: dependency: transitive description: name: shared_preferences_windows - sha256: "398084b47b7f92110683cac45c6dc4aae853db47e470e5ddcd52cab7f7196ab2" + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" shelf: dependency: transitive description: name: shelf - sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.2" shelf_web_socket: dependency: transitive description: name: shelf_web_socket - sha256: "073c147238594ecd0d193f3456a5fe91c4b0abbcc68bf5cd95b36c4e194ac611" + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "3.0.0" sky_engine: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" socket_io_client: dependency: "direct main" description: @@ -1481,10 +1497,10 @@ packages: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.10.1" sprintf: dependency: transitive description: @@ -1497,26 +1513,50 @@ packages: dependency: transitive description: name: sqflite - sha256: a43e5a27235518c03ca238e7b4732cf35eabe863a369ceba6cbefa537a66f16d + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 url: "https://pub.dev" source: hosted - version: "2.3.3+1" + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "3da423ce7baf868be70e2c0976c28a1bb2f73644268b7ffa7d2e08eab71f16a4" + sha256: "84731e8bfd8303a3389903e01fb2141b6e59b5973cacbb0929021df08dddbe8b" url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "2.5.5" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" stack_trace: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.1" state_notifier: dependency: transitive description: @@ -1529,26 +1569,26 @@ packages: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" stream_transform: dependency: transitive description: name: stream_transform - sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" string_scanner: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.4.1" sync_http: dependency: transitive description: @@ -1561,26 +1601,26 @@ packages: dependency: transitive description: name: synchronized - sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" + sha256: "0669c70faae6270521ee4f05bffd2919892d42d1276e6c495be80174b6bc0ef6" url: "https://pub.dev" source: hosted - version: "3.1.0+1" + version: "3.3.1" term_glyph: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" test_api: dependency: transitive description: name: test_api - sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.7.4" thumbhash: dependency: "direct main" description: @@ -1593,10 +1633,10 @@ packages: dependency: transitive description: name: time - sha256: ad8e018a6c9db36cb917a031853a1aae49467a93e0d464683e029537d848c221 + sha256: "370572cf5d1e58adcb3e354c47515da3f7469dac3a95b447117e728e7be6f461" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.1.5" timezone: dependency: "direct main" description: @@ -1609,18 +1649,18 @@ packages: dependency: transitive description: name: timing - sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.2" typed_data: dependency: transitive description: name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.4.0" universal_io: dependency: transitive description: @@ -1633,42 +1673,42 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: "21b704ce5fa560ea9f3b525b43601c678728ba46725bab9b01187b4831377ed3" + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.3.1" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: "94d8ad05f44c6d4e2ffe5567ab4d741b82d62e3c8e288cc1fcea45965edf47c9" + sha256: "1d0eae19bd7606ef60fe69ef3b312a437a16549476c42321d5dc1506c9ca3bf4" url: "https://pub.dev" source: hosted - version: "6.3.8" + version: "6.3.15" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: e43b677296fadce447e987a2f519dcf5f6d1e527dc35d01ffab4fff5b8a7063e + sha256: "16a513b6c12bb419304e72ea0ae2ab4fed569920d1c7cb850263fe3acc824626" url: "https://pub.dev" source: hosted - version: "6.3.1" + version: "6.3.2" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: ab360eb661f8879369acac07b6bb3ff09d9471155357da8443fd5d3cf7363811 + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.2.1" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "9a1a42d5d2d95400c795b2914c36fdcb525870c752569438e4ebb09a2b5d90de" + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "3.2.2" url_launcher_platform_interface: dependency: transitive description: @@ -1681,50 +1721,50 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" + sha256: "3ba963161bd0fe395917ba881d320b9c4f6dd3c4a233da62ab18a5025c85f1e9" url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "2.4.0" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "49c10f879746271804767cb45551ec5592cdab00ee105c06dddde1a98f73b185" + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.4" uuid: dependency: transitive description: name: uuid - sha256: "83d37c7ad7aaf9aa8e275490669535c8080377cfa7a7004c24dfac53afffaa90" + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff url: "https://pub.dev" source: hosted - version: "4.4.2" + version: "4.5.1" vector_graphics: dependency: transitive description: name: vector_graphics - sha256: "4ac59808bbfca6da38c99f415ff2d3a5d7ca0a6b4809c71d9cf30fba5daf9752" + sha256: "44cc7104ff32563122a929e4620cf3efd584194eec6d1d913eb5ba593dbcf6de" url: "https://pub.dev" source: hosted - version: "1.1.10+1" + version: "1.1.18" vector_graphics_codec: dependency: transitive description: name: vector_graphics_codec - sha256: f3247e7ab0ec77dc759263e68394990edc608fb2b480b80db8aa86ed09279e33 + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" url: "https://pub.dev" source: hosted - version: "1.1.10+1" + version: "1.1.13" vector_graphics_compiler: dependency: transitive description: name: vector_graphics_compiler - sha256: "18489bdd8850de3dd7ca8a34e0c446f719ec63e2bab2e7a8cc66a9028dd76c5a" + sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad" url: "https://pub.dev" source: hosted - version: "1.1.10+1" + version: "1.1.16" vector_math: dependency: transitive description: @@ -1737,42 +1777,42 @@ packages: dependency: transitive description: name: vm_service - sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" url: "https://pub.dev" source: hosted - version: "14.2.5" + version: "14.3.1" wakelock_plus: dependency: "direct main" description: name: wakelock_plus - sha256: bf4ee6f17a2fa373ed3753ad0e602b7603f8c75af006d5b9bdade263928c0484 + sha256: "36c88af0b930121941345306d259ec4cc4ecca3b151c02e3a9e71aede83c615e" url: "https://pub.dev" source: hosted - version: "1.2.8" + version: "1.2.10" wakelock_plus_platform_interface: dependency: transitive description: name: wakelock_plus_platform_interface - sha256: "422d1cdbb448079a8a62a5a770b69baa489f8f7ca21aef47800c726d404f9d16" + sha256: "70e780bc99796e1db82fe764b1e7dcb89a86f1e5b3afb1db354de50f2e41eb7a" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" watcher: dependency: transitive description: name: watcher - sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" web: dependency: transitive description: name: web - sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" web_socket: dependency: transitive description: @@ -1785,42 +1825,50 @@ packages: dependency: transitive description: name: web_socket_channel - sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" + sha256: "0b8e2457400d8a859b7b2030786835a28a8e80836ef64402abef392ff4f1d0e5" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" webdriver: dependency: transitive description: name: webdriver - sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e" + sha256: "3d773670966f02a646319410766d3b5e1037efb7f07cc68f844d5e06cd4d61c8" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.4" win32: dependency: transitive description: name: win32 - sha256: "015002c060f1ae9f41a818f2d5640389cc05283e368be19dc8d77cecb43c40c9" + sha256: b89e6e24d1454e149ab20fbb225af58660f0c0bf4475544650700d8e2da54aef url: "https://pub.dev" source: hosted - version: "5.5.3" + version: "5.11.0" win32_registry: dependency: transitive description: name: win32_registry - sha256: "723b7f851e5724c55409bb3d5a32b203b3afe8587eaf5dafb93a5fed8ecda0d6" + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" url: "https://pub.dev" source: hosted - version: "1.1.4" + version: "2.1.0" + window_to_front: + dependency: transitive + description: + name: window_to_front + sha256: "7aef379752b7190c10479e12b5fd7c0b9d92adc96817d9e96c59937929512aee" + url: "https://pub.dev" + source: hosted + version: "0.0.3" xdg_directories: dependency: transitive description: name: xdg_directories - sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.1.0" xml: dependency: transitive description: @@ -1833,18 +1881,18 @@ packages: dependency: transitive description: name: xxh3 - sha256: a92b30944a9aeb4e3d4f3c3d4ddb3c7816ca73475cd603682c4f8149690f56d7 + sha256: "399a0438f5d426785723c99da6b16e136f4953fb1e9db0bf270bd41dd4619916" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.2.0" yaml: dependency: transitive description: name: yaml - sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.3" sdks: - dart: ">=3.5.3 <4.0.0" - flutter: ">=3.24.5" + dart: ">=3.7.0 <4.0.0" + flutter: ">=3.29.1" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 140ec7291d..3e476d5441 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,11 +2,11 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.125.1+177 +version: 1.129.0+187 environment: sdk: '>=3.3.0 <4.0.0' - flutter: 3.24.5 + flutter: 3.29.1 isar_version: &isar_version 3.1.8 # define the version to be used @@ -14,19 +14,18 @@ dependencies: flutter: sdk: flutter - path_provider_ios: photo_manager: ^3.6.1 photo_manager_image_provider: ^2.2.0 - flutter_hooks: ^0.20.4 - hooks_riverpod: ^2.4.9 - riverpod_annotation: ^2.3.3 + flutter_hooks: ^0.21.2 + hooks_riverpod: ^2.6.1 + riverpod_annotation: ^2.6.1 cached_network_image: ^3.3.1 flutter_cache_manager: ^3.3.1 intl: ^0.19.0 auto_route: ^9.2.0 fluttertoast: ^8.2.4 socket_io_client: ^2.0.3+1 - maplibre_gl: 0.19.0+2 + maplibre_gl: ^0.21.0 geolocator: ^11.0.0 # used to move to current location in map view flutter_udid: ^3.0.0 flutter_svg: ^2.0.9 @@ -34,15 +33,16 @@ dependencies: url_launcher: ^6.2.4 http: ^1.1.0 cancellation_token_http: ^2.0.0 - easy_localization: ^3.0.3 + easy_localization: ^3.0.7+1 share_plus: ^10.0.0 flutter_displaymode: ^0.6.0 scrollable_positioned_list: ^0.3.8 path: ^1.8.3 - path_provider: ^2.1.2 + path_provider: ^2.1.5 + path_provider_foundation: ^2.4.1 collection: ^1.18.0 http_parser: ^4.0.2 - flutter_web_auth: 0.6.0 + flutter_web_auth_2: ^5.0.0-alpha.0 easy_image_viewer: ^1.4.0 isar: version: *isar_version @@ -65,7 +65,7 @@ dependencies: native_video_player: git: url: https://github.com/immich-app/native_video_player - ref: '4530808' + ref: '5459d54' #image editing packages crop_image: ^1.0.13 @@ -83,7 +83,7 @@ dependencies: # Taken from https://github.com/Myzel394/locus/blob/445013d22ec1d759027d4303bd65b30c5c8588c8/pubspec.yaml#L105 dependency_overrides: # TODO: remove once Isar is updated - analyzer: ^6.3.0 + analyzer: ^6.0.0 # TODO: remove once analyzer override is removed meta: ^1.11.0 # TODO: remove once analyzer override is removed @@ -108,11 +108,12 @@ dev_dependencies: integration_test: sdk: flutter custom_lint: ^0.6.4 - riverpod_lint: ^2.3.7 - riverpod_generator: ^2.3.9 + riverpod_lint: ^2.6.1 + riverpod_generator: ^2.6.1 mocktail: ^1.0.3 immich_mobile_immich_lint: path: './immich_lint' + fake_async: ^1.3.1 flutter: uses-material-design: true diff --git a/mobile/test/domain/services/log_service_test.dart b/mobile/test/domain/services/log_service_test.dart new file mode 100644 index 0000000000..5811a8c430 --- /dev/null +++ b/mobile/test/domain/services/log_service_test.dart @@ -0,0 +1,188 @@ +import 'package:collection/collection.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/domain/interfaces/log.interface.dart'; +import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/log.service.dart'; +import 'package:logging/logging.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../infrastructure/repository.mock.dart'; +import '../../test_utils.dart'; + +final _kInfoLog = LogMessage( + message: '#Info Message', + level: LogLevel.info, + createdAt: DateTime(2025, 2, 26), + logger: 'Info Logger', +); + +final _kWarnLog = LogMessage( + message: '#Warn Message', + level: LogLevel.warning, + createdAt: DateTime(2025, 2, 27), + logger: 'Warn Logger', +); + +void main() { + late LogService sut; + late ILogRepository mockLogRepo; + late IStoreRepository mockStoreRepo; + + setUp(() async { + mockLogRepo = MockLogRepository(); + mockStoreRepo = MockStoreRepository(); + + registerFallbackValue(_kInfoLog); + + when(() => mockLogRepo.truncate(limit: any(named: 'limit'))) + .thenAnswer((_) async => {}); + when(() => mockStoreRepo.tryGet(StoreKey.logLevel)) + .thenAnswer((_) async => LogLevel.fine.index); + when(() => mockLogRepo.getAll()).thenAnswer((_) async => []); + when(() => mockLogRepo.insert(any())).thenAnswer((_) async => true); + when(() => mockLogRepo.insertAll(any())).thenAnswer((_) async => true); + + sut = await LogService.create( + logRepository: mockLogRepo, + storeRepository: mockStoreRepo, + ); + }); + + tearDown(() async { + await sut.dispose(); + }); + + group("Log Service Init:", () { + test('Truncates the existing logs on init', () { + final limit = + verify(() => mockLogRepo.truncate(limit: captureAny(named: 'limit'))) + .captured + .firstOrNull as int?; + expect(limit, kLogTruncateLimit); + }); + + test('Sets log level based on the store setting', () { + verify(() => mockStoreRepo.tryGet(StoreKey.logLevel)).called(1); + expect(Logger.root.level, Level.FINE); + }); + }); + + group("Log Service Set Level:", () { + setUp(() async { + when(() => mockStoreRepo.insert(StoreKey.logLevel, any())) + .thenAnswer((_) async => true); + await sut.setlogLevel(LogLevel.shout); + }); + + test('Updates the log level in store', () { + final index = verify( + () => mockStoreRepo.insert(StoreKey.logLevel, captureAny()), + ).captured.firstOrNull; + expect(index, LogLevel.shout.index); + }); + + test('Sets log level on logger', () { + expect(Logger.root.level, Level.SHOUT); + }); + }); + + group("Log Service Buffer:", () { + test('Buffers logs until timer elapses', () { + TestUtils.fakeAsync((time) async { + sut = await LogService.create( + logRepository: mockLogRepo, + storeRepository: mockStoreRepo, + shouldBuffer: true, + ); + + final logger = Logger(_kInfoLog.logger!); + logger.info(_kInfoLog.message); + expect(await sut.getMessages(), hasLength(1)); + logger.warning(_kWarnLog.message); + expect(await sut.getMessages(), hasLength(2)); + time.elapse(const Duration(seconds: 6)); + expect(await sut.getMessages(), isEmpty); + }); + }); + + test('Batch inserts all logs on timer', () { + TestUtils.fakeAsync((time) async { + sut = await LogService.create( + logRepository: mockLogRepo, + storeRepository: mockStoreRepo, + shouldBuffer: true, + ); + + final logger = Logger(_kInfoLog.logger!); + logger.info(_kInfoLog.message); + time.elapse(const Duration(seconds: 6)); + final insert = verify(() => mockLogRepo.insertAll(captureAny())); + insert.called(1); + // ignore: prefer-correct-json-casts + final captured = insert.captured.firstOrNull as List; + expect(captured.firstOrNull?.message, _kInfoLog.message); + expect(captured.firstOrNull?.logger, _kInfoLog.logger); + + verifyNever(() => mockLogRepo.insert(captureAny())); + }); + }); + + test('Does not buffer when off', () { + TestUtils.fakeAsync((time) async { + sut = await LogService.create( + logRepository: mockLogRepo, + storeRepository: mockStoreRepo, + shouldBuffer: false, + ); + + final logger = Logger(_kInfoLog.logger!); + logger.info(_kInfoLog.message); + // Ensure nothing gets buffer. This works because we mock log repo getAll to return nothing + expect(await sut.getMessages(), isEmpty); + + final insert = verify(() => mockLogRepo.insert(captureAny())); + insert.called(1); + final captured = insert.captured.firstOrNull as LogMessage; + expect(captured.message, _kInfoLog.message); + expect(captured.logger, _kInfoLog.logger); + + verifyNever(() => mockLogRepo.insertAll(captureAny())); + }); + }); + }); + + group("Log Service Get messages:", () { + setUp(() { + when(() => mockLogRepo.getAll()).thenAnswer((_) async => [_kInfoLog]); + }); + + test('Fetches result from DB', () async { + expect(await sut.getMessages(), hasLength(1)); + verify(() => mockLogRepo.getAll()).called(1); + }); + + test('Combines result from both DB + Buffer', () { + TestUtils.fakeAsync((time) async { + sut = await LogService.create( + logRepository: mockLogRepo, + storeRepository: mockStoreRepo, + shouldBuffer: true, + ); + + final logger = Logger(_kWarnLog.logger!); + logger.warning(_kWarnLog.message); + expect(await sut.getMessages(), hasLength(2)); // 1 - DB, 1 - Buff + + final messages = await sut.getMessages(); + // Logged time is assigned in the service for messages in the buffer, so compare manually + expect(messages.firstOrNull?.message, _kWarnLog.message); + expect(messages.firstOrNull?.logger, _kWarnLog.logger); + + expect(messages.elementAtOrNull(1), _kInfoLog); + }); + }); + }); +} diff --git a/mobile/test/domain/services/store_service_test.dart b/mobile/test/domain/services/store_service_test.dart new file mode 100644 index 0000000000..554ca73500 --- /dev/null +++ b/mobile/test/domain/services/store_service_test.dart @@ -0,0 +1,184 @@ +// ignore_for_file: avoid-dynamic + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../infrastructure/repository.mock.dart'; + +const _kAccessToken = '#ThisIsAToken'; +const _kBackgroundBackup = false; +const _kGroupAssetsBy = 2; +final _kBackupFailedSince = DateTime.utc(2023); + +void main() { + late StoreService sut; + late IStoreRepository mockStoreRepo; + late StreamController controller; + + setUp(() async { + controller = StreamController.broadcast(); + mockStoreRepo = MockStoreRepository(); + // For generics, we need to provide fallback to each concrete type to avoid runtime errors + registerFallbackValue(StoreKey.accessToken); + registerFallbackValue(StoreKey.backupTriggerDelay); + registerFallbackValue(StoreKey.backgroundBackup); + registerFallbackValue(StoreKey.backupFailedSince); + + when(() => mockStoreRepo.tryGet(any>())) + .thenAnswer((invocation) async { + final key = invocation.positionalArguments.firstOrNull as StoreKey; + return switch (key) { + StoreKey.accessToken => _kAccessToken, + StoreKey.backgroundBackup => _kBackgroundBackup, + StoreKey.groupAssetsBy => _kGroupAssetsBy, + StoreKey.backupFailedSince => _kBackupFailedSince, + // ignore: avoid-wildcard-cases-with-enums + _ => null, + }; + }); + when(() => mockStoreRepo.watchAll()).thenAnswer((_) => controller.stream); + + sut = await StoreService.create(storeRepository: mockStoreRepo); + }); + + tearDown(() async { + sut.dispose(); + await controller.close(); + }); + + group("Store Service Init:", () { + test('Populates the internal cache on init', () { + verify(() => mockStoreRepo.tryGet(any>())) + .called(equals(StoreKey.values.length)); + expect(sut.tryGet(StoreKey.accessToken), _kAccessToken); + expect(sut.tryGet(StoreKey.backgroundBackup), _kBackgroundBackup); + expect(sut.tryGet(StoreKey.groupAssetsBy), _kGroupAssetsBy); + expect(sut.tryGet(StoreKey.backupFailedSince), _kBackupFailedSince); + // Other keys should be null + expect(sut.tryGet(StoreKey.currentUser), isNull); + }); + + test('Listens to stream of store updates', () async { + final event = + StoreUpdateEvent(StoreKey.accessToken, _kAccessToken.toUpperCase()); + controller.add(event); + + await pumpEventQueue(); + + verify(() => mockStoreRepo.watchAll()).called(1); + expect(sut.tryGet(StoreKey.accessToken), _kAccessToken.toUpperCase()); + }); + }); + + group('Store Service get:', () { + test('Returns the stored value for the given key', () { + expect(sut.get(StoreKey.accessToken), _kAccessToken); + }); + + test('Throws StoreKeyNotFoundException for nonexistent keys', () { + expect( + () => sut.get(StoreKey.currentUser), + throwsA(isA()), + ); + }); + + test('Returns the stored value for the given key or the defaultValue', () { + expect(sut.get(StoreKey.currentUser, 5), 5); + }); + }); + + group('Store Service put:', () { + setUp(() { + when(() => mockStoreRepo.insert(any>(), any())) + .thenAnswer((_) async => true); + }); + + test('Skip insert when value is not modified', () async { + await sut.put(StoreKey.accessToken, _kAccessToken); + verifyNever( + () => mockStoreRepo.insert(StoreKey.accessToken, any()), + ); + }); + + test('Insert value when modified', () async { + final newAccessToken = _kAccessToken.toUpperCase(); + await sut.put(StoreKey.accessToken, newAccessToken); + verify( + () => + mockStoreRepo.insert(StoreKey.accessToken, newAccessToken), + ).called(1); + expect(sut.tryGet(StoreKey.accessToken), newAccessToken); + }); + }); + + group('Store Service watch:', () { + late StreamController valueController; + + setUp(() { + valueController = StreamController.broadcast(); + when(() => mockStoreRepo.watch(any>())) + .thenAnswer((_) => valueController.stream); + }); + + tearDown(() async { + await valueController.close(); + }); + + test('Watches a specific key for changes', () async { + final stream = sut.watch(StoreKey.accessToken); + final events = [ + _kAccessToken, + _kAccessToken.toUpperCase(), + null, + _kAccessToken.toLowerCase(), + ]; + + expectLater(stream, emitsInOrder(events)); + + for (final event in events) { + valueController.add(event); + } + + await pumpEventQueue(); + verify(() => mockStoreRepo.watch(StoreKey.accessToken)).called(1); + }); + }); + + group('Store Service delete:', () { + setUp(() { + when(() => mockStoreRepo.delete(any>())) + .thenAnswer((_) async => true); + }); + + test('Removes the value from the DB', () async { + await sut.delete(StoreKey.accessToken); + verify(() => mockStoreRepo.delete(StoreKey.accessToken)) + .called(1); + }); + + test('Removes the value from the cache', () async { + await sut.delete(StoreKey.accessToken); + expect(sut.tryGet(StoreKey.accessToken), isNull); + }); + }); + + group('Store Service clear:', () { + setUp(() { + when(() => mockStoreRepo.deleteAll()).thenAnswer((_) async => true); + }); + + test('Clears all values from the store', () async { + await sut.clear(); + verify(() => mockStoreRepo.deleteAll()).called(1); + expect(sut.tryGet(StoreKey.accessToken), isNull); + expect(sut.tryGet(StoreKey.backgroundBackup), isNull); + expect(sut.tryGet(StoreKey.groupAssetsBy), isNull); + expect(sut.tryGet(StoreKey.backupFailedSince), isNull); + }); + }); +} diff --git a/mobile/test/fixtures/exif.stub.dart b/mobile/test/fixtures/exif.stub.dart new file mode 100644 index 0000000000..5ad9a41761 --- /dev/null +++ b/mobile/test/fixtures/exif.stub.dart @@ -0,0 +1,18 @@ +import 'package:immich_mobile/domain/models/exif.model.dart'; + +abstract final class ExifStub { + static final size = const ExifInfo(assetId: 1, fileSize: 1000); + + static final gps = const ExifInfo( + assetId: 2, + latitude: 20, + longitude: 20, + city: 'city', + state: 'state', + country: 'country', + ); + + static final rotated90CW = const ExifInfo(assetId: 3, orientation: "90"); + + static final rotated270CW = const ExifInfo(assetId: 4, orientation: "-90"); +} diff --git a/mobile/test/fixtures/user.stub.dart b/mobile/test/fixtures/user.stub.dart index 38524f782c..92efc93683 100644 --- a/mobile/test/fixtures/user.stub.dart +++ b/mobile/test/fixtures/user.stub.dart @@ -1,6 +1,6 @@ import 'package:immich_mobile/entities/user.entity.dart'; -final class UserStub { +abstract final class UserStub { const UserStub._(); static final admin = User( @@ -8,9 +8,9 @@ final class UserStub { updatedAt: DateTime(2021), email: "admin@test.com", name: "admin", - avatarColor: AvatarColorEnum.green, - profileImagePath: '', isAdmin: true, + profileImagePath: '', + avatarColor: AvatarColorEnum.green, ); static final user1 = User( @@ -18,9 +18,9 @@ final class UserStub { updatedAt: DateTime(2022), email: "user1@test.com", name: "user1", - avatarColor: AvatarColorEnum.red, - profileImagePath: '', isAdmin: false, + profileImagePath: '', + avatarColor: AvatarColorEnum.red, ); static final user2 = User( @@ -28,8 +28,8 @@ final class UserStub { updatedAt: DateTime(2023), email: "user2@test.com", name: "user2", - avatarColor: AvatarColorEnum.primary, - profileImagePath: '', isAdmin: false, + profileImagePath: '', + avatarColor: AvatarColorEnum.primary, ); } diff --git a/mobile/test/infrastructure/repositories/exif_repository_test.dart b/mobile/test/infrastructure/repositories/exif_repository_test.dart new file mode 100644 index 0000000000..e267d2dac4 --- /dev/null +++ b/mobile/test/infrastructure/repositories/exif_repository_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/interfaces/exif.interface.dart'; +import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/exif.repository.dart'; +import 'package:isar/isar.dart'; + +import '../../fixtures/exif.stub.dart'; +import '../../test_utils.dart'; + +Future _populateExifTable(Isar db) async { + await db.writeTxn(() async { + await db.exifInfos.putAll([ + ExifInfo.fromDto(ExifStub.size), + ExifInfo.fromDto(ExifStub.gps), + ExifInfo.fromDto(ExifStub.rotated90CW), + ExifInfo.fromDto(ExifStub.rotated270CW), + ]); + }); +} + +void main() { + late Isar db; + late IExifInfoRepository sut; + + setUp(() async { + db = await TestUtils.initIsar(); + sut = IsarExifRepository(db); + }); + + group("Return with proper orientation", () { + setUp(() async { + await _populateExifTable(db); + }); + + test("isFlipped true for 90CW", () async { + final exif = await sut.get(ExifStub.rotated90CW.assetId!); + expect(exif!.isFlipped, true); + }); + + test("isFlipped true for 270CW", () async { + final exif = await sut.get(ExifStub.rotated270CW.assetId!); + expect(exif!.isFlipped, true); + }); + + test("isFlipped false for the original non-rotated image", () async { + final exif = await sut.get(ExifStub.size.assetId!); + expect(exif!.isFlipped, false); + }); + }); +} diff --git a/mobile/test/infrastructure/repositories/store_repository_test.dart b/mobile/test/infrastructure/repositories/store_repository_test.dart new file mode 100644 index 0000000000..6fd3d3963a --- /dev/null +++ b/mobile/test/infrastructure/repositories/store_repository_test.dart @@ -0,0 +1,181 @@ +// ignore_for_file: avoid-dynamic + +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; +import 'package:isar/isar.dart'; + +import '../../fixtures/user.stub.dart'; +import '../../test_utils.dart'; + +const _kTestAccessToken = "#TestToken"; +final _kTestBackupFailed = DateTime(2025, 2, 20, 11, 45); +const _kTestVersion = 10; +const _kTestColorfulInterface = false; +final _kTestUser = UserStub.admin; + +Future _addIntStoreValue(Isar db, StoreKey key, int? value) async { + await db.storeValues.put(StoreValue(key.id, intValue: value, strValue: null)); +} + +Future _addStrStoreValue(Isar db, StoreKey key, String? value) async { + await db.storeValues.put(StoreValue(key.id, intValue: null, strValue: value)); +} + +Future _populateStore(Isar db) async { + await db.writeTxn(() async { + await _addIntStoreValue( + db, + StoreKey.colorfulInterface, + _kTestColorfulInterface ? 1 : 0, + ); + await _addIntStoreValue( + db, + StoreKey.backupFailedSince, + _kTestBackupFailed.millisecondsSinceEpoch, + ); + await _addStrStoreValue(db, StoreKey.accessToken, _kTestAccessToken); + await _addIntStoreValue(db, StoreKey.version, _kTestVersion); + }); +} + +void main() { + late Isar db; + late IStoreRepository sut; + + setUp(() async { + db = await TestUtils.initIsar(); + sut = IsarStoreRepository(db); + }); + + group('Store Repository converters:', () { + test('converts int', () async { + int? version = await sut.tryGet(StoreKey.version); + expect(version, isNull); + await sut.insert(StoreKey.version, _kTestVersion); + version = await sut.tryGet(StoreKey.version); + expect(version, _kTestVersion); + }); + + test('converts string', () async { + String? accessToken = await sut.tryGet(StoreKey.accessToken); + expect(accessToken, isNull); + await sut.insert(StoreKey.accessToken, _kTestAccessToken); + accessToken = await sut.tryGet(StoreKey.accessToken); + expect(accessToken, _kTestAccessToken); + }); + + test('converts datetime', () async { + DateTime? backupFailedSince = + await sut.tryGet(StoreKey.backupFailedSince); + expect(backupFailedSince, isNull); + await sut.insert(StoreKey.backupFailedSince, _kTestBackupFailed); + backupFailedSince = await sut.tryGet(StoreKey.backupFailedSince); + expect(backupFailedSince, _kTestBackupFailed); + }); + + test('converts bool', () async { + bool? colorfulInterface = await sut.tryGet(StoreKey.colorfulInterface); + expect(colorfulInterface, isNull); + await sut.insert(StoreKey.colorfulInterface, _kTestColorfulInterface); + colorfulInterface = await sut.tryGet(StoreKey.colorfulInterface); + expect(colorfulInterface, _kTestColorfulInterface); + }); + + test('converts user', () async { + User? user = await sut.tryGet(StoreKey.currentUser); + expect(user, isNull); + await sut.insert(StoreKey.currentUser, _kTestUser); + user = await sut.tryGet(StoreKey.currentUser); + expect(user, _kTestUser); + }); + }); + + group('Store Repository Deletes:', () { + setUp(() async { + await _populateStore(db); + }); + + test('delete()', () async { + bool? isColorful = await sut.tryGet(StoreKey.colorfulInterface); + expect(isColorful, isFalse); + await sut.delete(StoreKey.colorfulInterface); + isColorful = await sut.tryGet(StoreKey.colorfulInterface); + expect(isColorful, isNull); + }); + + test('deleteAll()', () async { + final count = await db.storeValues.count(); + expect(count, isNot(isZero)); + await sut.deleteAll(); + expectLater(await db.storeValues.count(), isZero); + }); + }); + + group('Store Repository Updates:', () { + setUp(() async { + await _populateStore(db); + }); + + test('update()', () async { + int? version = await sut.tryGet(StoreKey.version); + expect(version, _kTestVersion); + await sut.update(StoreKey.version, _kTestVersion + 10); + version = await sut.tryGet(StoreKey.version); + expect(version, _kTestVersion + 10); + }); + }); + + group('Store Repository Watchers:', () { + setUp(() async { + await _populateStore(db); + }); + + test('watch()', () async { + final stream = sut.watch(StoreKey.version); + expectLater(stream, emitsInOrder([_kTestVersion, _kTestVersion + 10])); + await pumpEventQueue(); + await sut.update(StoreKey.version, _kTestVersion + 10); + }); + + test('watchAll()', () async { + final stream = sut.watchAll(); + expectLater( + stream, + emitsInAnyOrder([ + emits( + const StoreUpdateEvent(StoreKey.version, _kTestVersion), + ), + emits( + StoreUpdateEvent( + StoreKey.backupFailedSince, + _kTestBackupFailed, + ), + ), + emits( + const StoreUpdateEvent( + StoreKey.accessToken, + _kTestAccessToken, + ), + ), + emits( + const StoreUpdateEvent( + StoreKey.colorfulInterface, + _kTestColorfulInterface, + ), + ), + emits( + const StoreUpdateEvent( + StoreKey.version, + _kTestVersion + 10, + ), + ), + ]), + ); + await sut.update(StoreKey.version, _kTestVersion + 10); + }); + }); +} diff --git a/mobile/test/infrastructure/repository.mock.dart b/mobile/test/infrastructure/repository.mock.dart new file mode 100644 index 0000000000..3e33fdac0a --- /dev/null +++ b/mobile/test/infrastructure/repository.mock.dart @@ -0,0 +1,7 @@ +import 'package:immich_mobile/domain/interfaces/log.interface.dart'; +import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockStoreRepository extends Mock implements IStoreRepository {} + +class MockLogRepository extends Mock implements ILogRepository {} diff --git a/mobile/test/modules/activity/activities_page_test.dart b/mobile/test/modules/activity/activities_page_test.dart index a5dda5dc44..6b20692bcd 100644 --- a/mobile/test/modules/activity/activities_page_test.dart +++ b/mobile/test/modules/activity/activities_page_test.dart @@ -4,18 +4,21 @@ library; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:immich_mobile/models/activities/activity.model.dart'; -import 'package:immich_mobile/providers/activity.provider.dart'; -import 'package:immich_mobile/pages/common/activities.page.dart'; -import 'package:immich_mobile/widgets/activities/activity_text_field.dart'; -import 'package:immich_mobile/widgets/activities/dismissible_activity.dart'; -import 'package:immich_mobile/providers/album/current_album.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; +import 'package:immich_mobile/models/activities/activity.model.dart'; +import 'package:immich_mobile/pages/common/activities.page.dart'; +import 'package:immich_mobile/providers/activity.provider.dart'; +import 'package:immich_mobile/providers/album/current_album.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/widgets/activities/activity_text_field.dart'; +import 'package:immich_mobile/widgets/activities/dismissible_activity.dart'; import 'package:isar/isar.dart'; import 'package:mocktail/mocktail.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -25,8 +28,8 @@ import '../../fixtures/asset.stub.dart'; import '../../fixtures/user.stub.dart'; import '../../test_utils.dart'; import '../../widget_tester_extensions.dart'; -import '../asset_viewer/asset_viewer_mocks.dart'; import '../album/album_mocks.dart'; +import '../asset_viewer/asset_viewer_mocks.dart'; import '../shared/shared_mocks.dart'; import 'activity_mocks.dart'; @@ -71,7 +74,7 @@ void main() { setUpAll(() async { TestUtils.init(); db = await TestUtils.initIsar(); - Store.init(db); + await StoreService.init(storeRepository: IsarStoreRepository(db)); Store.put(StoreKey.currentUser, UserStub.admin); Store.put(StoreKey.serverEndpoint, ''); Store.put(StoreKey.accessToken, ''); diff --git a/mobile/test/modules/activity/activity_text_field_test.dart b/mobile/test/modules/activity/activity_text_field_test.dart index caa742873a..a124af0db9 100644 --- a/mobile/test/modules/activity/activity_text_field_test.dart +++ b/mobile/test/modules/activity/activity_text_field_test.dart @@ -4,11 +4,14 @@ library; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:immich_mobile/providers/activity.provider.dart'; -import 'package:immich_mobile/widgets/activities/activity_text_field.dart'; -import 'package:immich_mobile/providers/album/current_album.provider.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; +import 'package:immich_mobile/providers/activity.provider.dart'; +import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/widgets/activities/activity_text_field.dart'; import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; import 'package:isar/isar.dart'; import 'package:mocktail/mocktail.dart'; @@ -31,7 +34,7 @@ void main() { setUpAll(() async { TestUtils.init(); db = await TestUtils.initIsar(); - Store.init(db); + await StoreService.init(storeRepository: IsarStoreRepository(db)); Store.put(StoreKey.currentUser, UserStub.admin); Store.put(StoreKey.serverEndpoint, ''); }); diff --git a/mobile/test/modules/activity/activity_tile_test.dart b/mobile/test/modules/activity/activity_tile_test.dart index f64eea851a..22dd606540 100644 --- a/mobile/test/modules/activity/activity_tile_test.dart +++ b/mobile/test/modules/activity/activity_tile_test.dart @@ -5,10 +5,13 @@ library; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/models/activities/activity.model.dart'; -import 'package:immich_mobile/widgets/activities/activity_tile.dart'; -import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; +import 'package:immich_mobile/models/activities/activity.model.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/widgets/activities/activity_tile.dart'; import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; import 'package:isar/isar.dart'; @@ -27,7 +30,7 @@ void main() { TestUtils.init(); db = await TestUtils.initIsar(); // For UserCircleAvatar - Store.init(db); + await StoreService.init(storeRepository: IsarStoreRepository(db)); Store.put(StoreKey.currentUser, UserStub.admin); Store.put(StoreKey.serverEndpoint, ''); Store.put(StoreKey.accessToken, ''); diff --git a/mobile/test/modules/extensions/asset_extensions_test.dart b/mobile/test/modules/extensions/asset_extensions_test.dart index d2b9b93d62..dd334c7b9d 100644 --- a/mobile/test/modules/extensions/asset_extensions_test.dart +++ b/mobile/test/modules/extensions/asset_extensions_test.dart @@ -1,7 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:immich_mobile/extensions/asset_extensions.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/extensions/asset_extensions.dart'; import 'package:timezone/data/latest.dart'; import 'package:timezone/timezone.dart'; diff --git a/mobile/test/modules/map/map_theme_override_test.dart b/mobile/test/modules/map/map_theme_override_test.dart index bd000c8715..5a6b163c04 100644 --- a/mobile/test/modules/map/map_theme_override_test.dart +++ b/mobile/test/modules/map/map_theme_override_test.dart @@ -4,10 +4,13 @@ library; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/models/map/map_state.model.dart'; import 'package:immich_mobile/providers/locale_provider.dart'; import 'package:immich_mobile/providers/map/map_state.provider.dart'; import 'package:immich_mobile/widgets/map/map_theme_override.dart'; +import 'package:isar/isar.dart'; import '../../test_utils.dart'; import '../../widget_tester_extensions.dart'; @@ -17,14 +20,17 @@ void main() { late MockMapStateNotifier mapStateNotifier; late List overrides; late MapState mapState; + late Isar db; setUpAll(() async { TestUtils.init(); + db = await TestUtils.initIsar(); }); - setUp(() { + setUp(() async { mapState = MapState(themeMode: ThemeMode.dark); mapStateNotifier = MockMapStateNotifier(mapState); + await StoreService.init(storeRepository: IsarStoreRepository(db)); overrides = [ mapStateNotifierProvider.overrideWith(() => mapStateNotifier), localeProvider.overrideWithValue(const Locale("en")), diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart index c85487c7d0..a58de21613 100644 --- a/mobile/test/modules/shared/sync_service_test.dart +++ b/mobile/test/modules/shared/sync_service_test.dart @@ -1,12 +1,16 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/log.service.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/log.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/user.interface.dart'; -import 'package:immich_mobile/services/immich_logger.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:mocktail/mocktail.dart'; @@ -63,10 +67,14 @@ void main() { setUpAll(() async { WidgetsFlutterBinding.ensureInitialized(); final db = await TestUtils.initIsar(); - ImmichLogger(); + db.writeTxnSync(() => db.clearSync()); - Store.init(db); + await StoreService.init(storeRepository: IsarStoreRepository(db)); await Store.put(StoreKey.currentUser, owner); + await LogService.init( + logRepository: IsarLogRepository(db), + storeRepository: IsarStoreRepository(db), + ); }); final List initialAssets = [ makeAsset(checksum: "a", remoteId: "0-1"), @@ -105,7 +113,7 @@ void main() { when(() => assetRepository.getAllByOwnerIdChecksum(any(), any())) .thenAnswer((_) async => [initialAssets[3], null, null]); when(() => assetRepository.updateAll(any())).thenAnswer((_) async => []); - when(() => assetRepository.deleteById(any())).thenAnswer((_) async {}); + when(() => assetRepository.deleteByIds(any())).thenAnswer((_) async {}); when(() => exifInfoRepository.updateAll(any())) .thenAnswer((_) async => []); when(() => assetRepository.transaction(any())).thenAnswer( diff --git a/mobile/test/pages/search/search.page_test.dart b/mobile/test/pages/search/search.page_test.dart index 8cdf610433..fa7f037da5 100644 --- a/mobile/test/pages/search/search.page_test.dart +++ b/mobile/test/pages/search/search.page_test.dart @@ -1,12 +1,16 @@ +@Skip('currently failing due to mock HTTP client to download ISAR binaries') +@Tags(['pages']) +library; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/pages/search/search.page.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/providers/search/paginated_search.provider.dart'; -import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:isar/isar.dart'; import 'package:mocktail/mocktail.dart'; import 'package:openapi/api.dart'; @@ -25,16 +29,15 @@ void main() { setUpAll(() async { TestUtils.init(); db = await TestUtils.initIsar(); - Store.init(db); + await StoreService.init(storeRepository: IsarStoreRepository(db)); mockApiService = MockApiService(); mockSearchApi = MockSearchApi(); when(() => mockApiService.searchApi).thenReturn(mockSearchApi); registerFallbackValue(MockSmartSearchDto()); registerFallbackValue(MockMetadataSearchDto()); overrides = [ - paginatedSearchRenderListProvider - .overrideWithValue(AsyncValue.data(RenderList.empty())), dbProvider.overrideWithValue(db), + isarProvider.overrideWithValue(db), apiServiceProvider.overrideWithValue(mockApiService), ]; }); @@ -118,72 +121,4 @@ void main() { captured = verify(() => mockSearchApi.searchAssets(captureAny())).captured; expect(captured.first, emptyTextSearch); }); - - // COME BACK LATER - // testWidgets('contextual search with text combined with media type', - // (tester) async { - // await tester.pumpConsumerWidget( - // const SearchPage(), - // overrides: overrides, - // ); - - // await tester.pumpAndSettle(); - - // expect( - // find.byIcon(Icons.abc_rounded), - // findsOneWidget, - // reason: 'Should have contextual search icon', - // ); - - // final searchField = find.byKey(const Key('search_text_field')); - // expect(searchField, findsOneWidget); - - // await tester.enterText(searchField, 'test'); - // await tester.testTextInput.receiveAction(TextInputAction.search); - - // var captured = verify( - // () => mockSearchApi.searchSmart(captureAny()), - // ).captured; - - // expect( - // captured.first, - // isA().having((s) => s.query, 'query', 'test'), - // ); - - // await tester.dragUntilVisible( - // find.byKey(const Key('media_type_chip')), - // find.byKey(const Key('search_filter_chip_list')), - // const Offset(-100, 0), - // ); - // await tester.pumpAndSettle(); - - // await tester.tap(find.byKey(const Key('media_type_chip'))); - // await tester.pumpAndSettle(); - - // await tester.tap(find.byKey(const Key('search_filter_media_type_image'))); - // await tester.pumpAndSettle(); - - // await tester.tap(find.byKey(const Key('search_filter_apply'))); - // await tester.pumpAndSettle(); - - // captured = verify(() => mockSearchApi.searchSmart(captureAny())).captured; - - // expect( - // captured.first, - // isA() - // .having((s) => s.query, 'query', 'test') - // .having((s) => s.type, 'type', AssetTypeEnum.IMAGE), - // ); - - // await tester.enterText(searchField, ''); - // await tester.testTextInput.receiveAction(TextInputAction.search); - - // captured = verify(() => mockSearchApi.searchAssets(captureAny())).captured; - // expect( - // captured.first, - // isA() - // .having((s) => s.originalFileName, 'originalFileName', null) - // .having((s) => s.type, 'type', AssetTypeEnum.IMAGE), - // ); - // }); } diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart index 3dda932cac..7443db2815 100644 --- a/mobile/test/repository.mocks.dart +++ b/mobile/test/repository.mocks.dart @@ -1,3 +1,4 @@ +import 'package:immich_mobile/domain/interfaces/exif.interface.dart'; import 'package:immich_mobile/interfaces/album.interface.dart'; import 'package:immich_mobile/interfaces/album_api.interface.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; @@ -5,9 +6,8 @@ import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset_media.interface.dart'; import 'package:immich_mobile/interfaces/auth.interface.dart'; import 'package:immich_mobile/interfaces/auth_api.interface.dart'; -import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/backup_album.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart'; -import 'package:immich_mobile/interfaces/exif_info.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:mocktail/mocktail.dart'; @@ -18,7 +18,7 @@ class MockAssetRepository extends Mock implements IAssetRepository {} class MockUserRepository extends Mock implements IUserRepository {} -class MockBackupRepository extends Mock implements IBackupRepository {} +class MockBackupRepository extends Mock implements IBackupAlbumRepository {} class MockExifInfoRepository extends Mock implements IExifInfoRepository {} diff --git a/mobile/test/services/album.service_test.dart b/mobile/test/services/album.service_test.dart index c0775a1c3e..983b355dcb 100644 --- a/mobile/test/services/album.service_test.dart +++ b/mobile/test/services/album.service_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:mocktail/mocktail.dart'; + import '../fixtures/album.stub.dart'; import '../fixtures/asset.stub.dart'; import '../fixtures/user.stub.dart'; @@ -83,7 +84,9 @@ void main() { group('refreshRemoteAlbums', () { test('is working', () async { - when(() => userService.refreshUsers()).thenAnswer((_) async => true); + when(() => userService.getUsersFromServer()).thenAnswer((_) async => []); + when(() => syncService.syncUsersFromServer(any())) + .thenAnswer((_) async => true); when(() => albumApiRepository.getAll(shared: true)) .thenAnswer((_) async => [AlbumStub.sharedWithUser]); @@ -99,7 +102,8 @@ void main() { ).thenAnswer((_) async => true); final result = await sut.refreshRemoteAlbums(); expect(result, true); - verify(() => userService.refreshUsers()).called(1); + verify(() => userService.getUsersFromServer()).called(1); + verify(() => syncService.syncUsersFromServer([])).called(1); verify(() => albumApiRepository.getAll(shared: true)).called(1); verify(() => albumApiRepository.getAll(shared: null)).called(1); verify( diff --git a/mobile/test/services/auth.service_test.dart b/mobile/test/services/auth.service_test.dart index edbf6495e3..e4f011d940 100644 --- a/mobile/test/services/auth.service_test.dart +++ b/mobile/test/services/auth.service_test.dart @@ -1,10 +1,13 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/services/auth.service.dart'; +import 'package:isar/isar.dart'; import 'package:mocktail/mocktail.dart'; import 'package:openapi/api.dart'; + import '../repository.mocks.dart'; import '../service.mocks.dart'; import '../test_utils.dart'; @@ -15,6 +18,7 @@ void main() { late MockAuthRepository authRepository; late MockApiService apiService; late MockNetworkService networkService; + late Isar db; setUp(() async { authApiRepository = MockAuthApiRepository(); @@ -32,12 +36,18 @@ void main() { registerFallbackValue(Uri()); }); + setUpAll(() async { + db = await TestUtils.initIsar(); + db.writeTxnSync(() => db.clearSync()); + await StoreService.init(storeRepository: IsarStoreRepository(db)); + }); + group('validateServerUrl', () { setUpAll(() async { WidgetsFlutterBinding.ensureInitialized(); final db = await TestUtils.initIsar(); db.writeTxnSync(() => db.clearSync()); - Store.init(db); + await StoreService.init(storeRepository: IsarStoreRepository(db)); }); test('Should resolve HTTP endpoint', () async { diff --git a/mobile/test/test_utils.dart b/mobile/test/test_utils.dart index 7afd209f10..19b2d6e705 100644 --- a/mobile/test/test_utils.dart +++ b/mobile/test/test_utils.dart @@ -1,19 +1,21 @@ +import 'dart:async'; import 'dart:io'; import 'package:easy_localization/easy_localization.dart'; +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/entities/backup_album.entity.dart'; -import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/android_device_asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; -import 'package:immich_mobile/entities/logger_message.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/log.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; import 'package:isar/isar.dart'; import 'package:mocktail/mocktail.dart'; @@ -21,10 +23,11 @@ import 'mock_http_override.dart'; // Listener Mock to test when a provider notifies its listeners class ListenerMock extends Mock { + // ignore: avoid-declaring-call-method void call(T? previous, T next); } -final class TestUtils { +abstract final class TestUtils { const TestUtils._(); /// Downloads Isar binaries (if required) and initializes a new Isar db @@ -50,13 +53,14 @@ final class TestUtils { AndroidDeviceAssetSchema, IOSDeviceAssetSchema, ], - maxSizeMiB: 1024, directory: "test/", + maxSizeMiB: 1024, + inspector: false, ); // Clear and close db on test end addTearDown(() async { - await db.writeTxn(() => db.clear()); + await db.writeTxn(() async => await db.clear()); await db.close(); }); return db; @@ -86,4 +90,36 @@ final class TestUtils { WidgetController.hitTestWarningShouldBeFatal = true; HttpOverrides.global = MockHttpOverrides(); } + + // Workaround till the following issue is resolved + // https://github.com/dart-lang/test/issues/2307 + static T fakeAsync( + Future Function(FakeAsync _) callback, { + DateTime? initialTime, + }) { + late final T result; + Object? error; + StackTrace? stack; + FakeAsync(initialTime: initialTime).run((FakeAsync async) { + bool shouldPump = true; + unawaited( + callback(async).then( + (value) => result = value, + onError: (e, s) { + error = e; + stack = s; + }, + ).whenComplete(() => shouldPump = false), + ); + + while (shouldPump) { + async.flushMicrotasks(); + } + }); + + if (error != null) { + Error.throwWithStackTrace(error!, stack!); + } + return result; + } } diff --git a/open-api/bin/generate-open-api.sh b/open-api/bin/generate-open-api.sh index bf8b24b557..e2badc6dff 100755 --- a/open-api/bin/generate-open-api.sh +++ b/open-api/bin/generate-open-api.sh @@ -9,7 +9,11 @@ function dart { wget -O native_class.mustache https://raw.githubusercontent.com/OpenAPITools/openapi-generator/$OPENAPI_GENERATOR_VERSION/modules/openapi-generator/src/main/resources/dart2/serialization/native/native_class.mustache patch --no-backup-if-mismatch -u native_class.mustache header}} +{{>part_of}} +{{#operations}} + +class {{{classname}}} { + {{{classname}}}([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + {{#operation}} + + {{#summary}} + /// {{{.}}} + {{/summary}} + {{#notes}} + {{#summary}} + /// + {{/summary}} + /// {{{notes}}} + /// + /// Note: This method returns the HTTP [Response]. + {{/notes}} + {{^notes}} + {{#summary}} + /// + /// Note: This method returns the HTTP [Response]. + {{/summary}} + {{^summary}} + /// Performs an HTTP '{{{httpMethod}}} {{{path}}}' operation and returns the [Response]. + {{/summary}} + {{/notes}} + {{#hasParams}} + {{#summary}} + /// + {{/summary}} + {{^summary}} + {{#notes}} + /// + {{/notes}} + {{/summary}} + /// Parameters: + /// + {{/hasParams}} + {{#allParams}} + /// * [{{{dataType}}}] {{{paramName}}}{{#required}} (required){{/required}}{{#optional}} (optional){{/optional}}: + {{#description}} + /// {{{.}}} + {{/description}} + {{^-last}} + /// + {{/-last}} + {{/allParams}} + Future {{{nickname}}}WithHttpInfo({{#allParams}}{{#required}}{{{dataType}}} {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}}{{#hasOptionalParams}}{ {{#allParams}}{{^required}}{{{dataType}}}? {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}} }{{/hasOptionalParams}}) async { + // ignore: prefer_const_declarations + final apiPath = r'{{{path}}}'{{#pathParams}} + .replaceAll({{=<% %>=}}'{<% baseName %>}'<%={{ }}=%>, {{{paramName}}}{{^isString}}.toString(){{/isString}}){{/pathParams}}; + + // ignore: prefer_final_locals + Object? postBody{{#bodyParam}} = {{{paramName}}}{{/bodyParam}}; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + {{#hasQueryParams}} + + {{#queryParams}} + {{^required}} + if ({{{paramName}}} != null) { + {{/required}} + queryParams.addAll(_queryParams('{{{collectionFormat}}}', '{{{baseName}}}', {{{paramName}}})); + {{^required}} + } + {{/required}} + {{/queryParams}} + {{/hasQueryParams}} + {{#hasHeaderParams}} + + {{#headerParams}} + {{#required}} + headerParams[r'{{{baseName}}}'] = parameterToString({{{paramName}}}); + {{/required}} + {{^required}} + if ({{{paramName}}} != null) { + headerParams[r'{{{baseName}}}'] = parameterToString({{{paramName}}}); + } + {{/required}} + {{/headerParams}} + {{/hasHeaderParams}} + + const contentTypes = [{{#prioritizedContentTypes}}'{{{mediaType}}}'{{^-last}}, {{/-last}}{{/prioritizedContentTypes}}]; + + {{#isMultipart}} + bool hasFields = false; + final mp = MultipartRequest('{{{httpMethod}}}', Uri.parse(apiPath)); + {{#formParams}} + {{^isFile}} + if ({{{paramName}}} != null) { + hasFields = true; + mp.fields[r'{{{baseName}}}'] = parameterToString({{{paramName}}}); + } + {{/isFile}} + {{#isFile}} + if ({{{paramName}}} != null) { + hasFields = true; + mp.fields[r'{{{baseName}}}'] = {{{paramName}}}.field; + mp.files.add({{{paramName}}}); + } + {{/isFile}} + {{/formParams}} + if (hasFields) { + postBody = mp; + } + {{/isMultipart}} + {{^isMultipart}} + {{#formParams}} + {{^isFile}} + if ({{{paramName}}} != null) { + formParams[r'{{{baseName}}}'] = parameterToString({{{paramName}}}); + } + {{/isFile}} + {{/formParams}} + {{/isMultipart}} + + return apiClient.invokeAPI( + apiPath, + '{{{httpMethod}}}', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + {{#summary}} + /// {{{.}}} + {{/summary}} + {{#notes}} + {{#summary}} + /// + {{/summary}} + /// {{{notes}}} + {{/notes}} + {{#hasParams}} + {{#summary}} + /// + {{/summary}} + {{^summary}} + {{#notes}} + /// + {{/notes}} + {{/summary}} + /// Parameters: + /// + {{/hasParams}} + {{#allParams}} + /// * [{{{dataType}}}] {{{paramName}}}{{#required}} (required){{/required}}{{#optional}} (optional){{/optional}}: + {{#description}} + /// {{{.}}} + {{/description}} + {{^-last}} + /// + {{/-last}} + {{/allParams}} + Future<{{#returnType}}{{{.}}}?{{/returnType}}{{^returnType}}void{{/returnType}}> {{{nickname}}}({{#allParams}}{{#required}}{{{dataType}}} {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}}{{#hasOptionalParams}}{ {{#allParams}}{{^required}}{{{dataType}}}? {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}} }{{/hasOptionalParams}}) async { + final response = await {{{nickname}}}WithHttpInfo({{#allParams}}{{#required}}{{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}}{{#hasOptionalParams}} {{#allParams}}{{^required}}{{{paramName}}}: {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}} {{/hasOptionalParams}}); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + {{#returnType}} + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + {{#native_serialization}} + {{#isArray}} + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, '{{{returnType}}}') as List) + .cast<{{{returnBaseType}}}>() + .{{#uniqueItems}}toSet(){{/uniqueItems}}{{^uniqueItems}}toList(growable: false){{/uniqueItems}}; + {{/isArray}} + {{^isArray}} + {{#isMap}} + return {{{returnType}}}.from(await apiClient.deserializeAsync(await _decodeBodyBytes(response), '{{{returnType}}}'),); + {{/isMap}} + {{^isMap}} + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), '{{{returnType}}}',) as {{{returnType}}}; + {{/isMap}}{{/isArray}}{{/native_serialization}} + } + return null; + {{/returnType}} + } + {{/operation}} +} +{{/operations}} diff --git a/open-api/templates/mobile/api.mustache.patch b/open-api/templates/mobile/api.mustache.patch new file mode 100644 index 0000000000..e3f888d6d7 --- /dev/null +++ b/open-api/templates/mobile/api.mustache.patch @@ -0,0 +1,29 @@ +--- api.mustache 2025-01-22 05:50:25 ++++ api.mustache.modified 2025-01-22 05:52:23 +@@ -51,7 +51,7 @@ + {{/allParams}} + Future {{{nickname}}}WithHttpInfo({{#allParams}}{{#required}}{{{dataType}}} {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}}{{#hasOptionalParams}}{ {{#allParams}}{{^required}}{{{dataType}}}? {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}} }{{/hasOptionalParams}}) async { + // ignore: prefer_const_declarations +- final path = r'{{{path}}}'{{#pathParams}} ++ final apiPath = r'{{{path}}}'{{#pathParams}} + .replaceAll({{=<% %>=}}'{<% baseName %>}'<%={{ }}=%>, {{{paramName}}}{{^isString}}.toString(){{/isString}}){{/pathParams}}; + + // ignore: prefer_final_locals +@@ -90,7 +90,7 @@ + + {{#isMultipart}} + bool hasFields = false; +- final mp = MultipartRequest('{{{httpMethod}}}', Uri.parse(path)); ++ final mp = MultipartRequest('{{{httpMethod}}}', Uri.parse(apiPath)); + {{#formParams}} + {{^isFile}} + if ({{{paramName}}} != null) { +@@ -121,7 +121,7 @@ + {{/isMultipart}} + + return apiClient.invokeAPI( +- path, ++ apiPath, + '{{{httpMethod}}}', + queryParams, + postBody, diff --git a/open-api/typescript-sdk/.nvmrc b/open-api/typescript-sdk/.nvmrc index d5b283a3ac..7d41c735d7 100644 --- a/open-api/typescript-sdk/.nvmrc +++ b/open-api/typescript-sdk/.nvmrc @@ -1 +1 @@ -22.13.1 +22.14.0 diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 45410e78a0..149c1ed2ce 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,30 +1,31 @@ { "name": "@immich/sdk", - "version": "1.125.1", + "version": "1.129.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.125.1", + "version": "1.129.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.10.9", + "@types/node": "^22.13.9", "typescript": "^5.3.3" } }, "node_modules/@oazapfts/runtime": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@oazapfts/runtime/-/runtime-1.0.3.tgz", - "integrity": "sha512-8tKiYffhwTGHSHYGnZ3oneLGCjX0po/XAXQ5Ng9fqKkvIdl/xz8+Vh8i+6xjzZqvZ2pLVpUcuSfnvNI/x67L0g==" + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@oazapfts/runtime/-/runtime-1.0.4.tgz", + "integrity": "sha512-7t6C2shug/6tZhQgkCa532oTYBLEnbASV/i1SG1rH2GB4h3aQQujYciYSPT92hvN4IwTe8S2hPkN/6iiOyTlCg==", + "license": "MIT" }, "node_modules/@types/node": { - "version": "22.10.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.9.tgz", - "integrity": "sha512-Ir6hwgsKyNESl/gLOcEz3krR4CBGgliDqBQ2ma4wIhEx0w+xnoeTq3tdrNw15kU3SxogDjOgv9sqdtLW8mIHaw==", + "version": "22.13.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", + "integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==", "dev": true, "license": "MIT", "dependencies": { @@ -32,9 +33,9 @@ } }, "node_modules/typescript": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", - "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 5d94e0e70d..b3fef2a87e 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.125.1", + "version": "1.129.0", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", @@ -19,7 +19,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.10.9", + "@types/node": "^22.13.9", "typescript": "^5.3.3" }, "repository": { @@ -28,6 +28,6 @@ "directory": "open-api/typescript-sdk" }, "volta": { - "node": "22.13.1" + "node": "22.14.0" } } diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 7a4c0cd7ee..4c058371f4 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.125.1 + * 1.129.0 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ @@ -113,6 +113,10 @@ export type PurchaseResponse = { export type RatingsResponse = { enabled: boolean; }; +export type SharedLinksResponse = { + enabled: boolean; + sidebarWeb: boolean; +}; export type TagsResponse = { enabled: boolean; sidebarWeb: boolean; @@ -126,6 +130,7 @@ export type UserPreferencesResponseDto = { people: PeopleResponse; purchase: PurchaseResponse; ratings: RatingsResponse; + sharedLinks: SharedLinksResponse; tags: TagsResponse; }; export type AvatarUpdate = { @@ -158,6 +163,10 @@ export type PurchaseUpdate = { export type RatingsUpdate = { enabled?: boolean; }; +export type SharedLinksUpdate = { + enabled?: boolean; + sidebarWeb?: boolean; +}; export type TagsUpdate = { enabled?: boolean; sidebarWeb?: boolean; @@ -171,6 +180,7 @@ export type UserPreferencesUpdateDto = { people?: PeopleUpdate; purchase?: PurchaseUpdate; ratings?: RatingsUpdate; + sharedLinks?: SharedLinksUpdate; tags?: TagsUpdate; }; export type AlbumUserResponseDto = { @@ -213,8 +223,12 @@ export type AssetFaceWithoutPersonResponseDto = { }; export type PersonWithFacesResponseDto = { birthDate: string | null; + /** This property was added in v1.126.0 */ + color?: string; faces: AssetFaceWithoutPersonResponseDto[]; id: string; + /** This property was added in v1.126.0 */ + isFavorite?: boolean; isHidden: boolean; name: string; thumbnailPath: string; @@ -435,10 +449,6 @@ export type AssetMediaReplaceDto = { fileCreatedAt: string; fileModifiedAt: string; }; -export type AuditDeletesResponseDto = { - ids: string[]; - needsFullSync: boolean; -}; export type SignUpDto = { email: string; name: string; @@ -491,7 +501,11 @@ export type DuplicateResponseDto = { }; export type PersonResponseDto = { birthDate: string | null; + /** This property was added in v1.126.0 */ + color?: string; id: string; + /** This property was added in v1.126.0 */ + isFavorite?: boolean; isHidden: boolean; name: string; thumbnailPath: string; @@ -509,6 +523,19 @@ export type AssetFaceResponseDto = { person: (PersonResponseDto) | null; sourceType?: SourceType; }; +export type AssetFaceCreateDto = { + assetId: string; + height: number; + imageHeight: number; + imageWidth: number; + personId: string; + width: number; + x: number; + y: number; +}; +export type AssetFaceDeleteDto = { + force: boolean; +}; export type FaceDto = { id: string; }; @@ -613,11 +640,13 @@ export type MemoryResponseDto = { createdAt: string; data: OnThisDayDto; deletedAt?: string; + hideAt?: string; id: string; isSaved: boolean; memoryAt: string; ownerId: string; seenAt?: string; + showAt?: string; "type": MemoryType; updatedAt: string; }; @@ -689,6 +718,8 @@ export type PersonCreateDto = { /** Person date of birth. Note: the mobile app cannot currently set the birth date to null. */ birthDate?: string | null; + color?: string | null; + isFavorite?: boolean; /** Person visibility */ isHidden?: boolean; /** Person name. */ @@ -698,10 +729,12 @@ export type PeopleUpdateItem = { /** Person date of birth. Note: the mobile app cannot currently set the birth date to null. */ birthDate?: string | null; + color?: string | null; /** Asset is used to get the feature face thumbnail. */ featureFaceAssetId?: string; /** Person id. */ id: string; + isFavorite?: boolean; /** Person visibility */ isHidden?: boolean; /** Person name. */ @@ -714,8 +747,10 @@ export type PersonUpdateDto = { /** Person date of birth. Note: the mobile app cannot currently set the birth date to null. */ birthDate?: string | null; + color?: string | null; /** Asset is used to get the feature face thumbnail. */ featureFaceAssetId?: string; + isFavorite?: boolean; /** Person visibility */ isHidden?: boolean; /** Person name. */ @@ -769,6 +804,7 @@ export type MetadataSearchDto = { country?: string | null; createdAfter?: string; createdBefore?: string; + description?: string; deviceAssetId?: string; deviceId?: string; encodedVideoPath?: string; @@ -790,8 +826,10 @@ export type MetadataSearchDto = { page?: number; personIds?: string[]; previewPath?: string; + rating?: number; size?: number; state?: string | null; + tagIds?: string[]; takenAfter?: string; takenBefore?: string; thumbnailPath?: string; @@ -856,8 +894,10 @@ export type RandomSearchDto = { make?: string; model?: string | null; personIds?: string[]; + rating?: number; size?: number; state?: string | null; + tagIds?: string[]; takenAfter?: string; takenBefore?: string; trashedAfter?: string; @@ -891,8 +931,10 @@ export type SmartSearchDto = { page?: number; personIds?: string[]; query: string; + rating?: number; size?: number; state?: string | null; + tagIds?: string[]; takenAfter?: string; takenBefore?: string; trashedAfter?: string; @@ -1077,6 +1119,16 @@ export type StackCreateDto = { export type StackUpdateDto = { primaryAssetId?: string; }; +export type SyncAckDeleteDto = { + types?: SyncEntityType[]; +}; +export type SyncAckDto = { + ack: string; + "type": SyncEntityType; +}; +export type SyncAckSetDto = { + acks: string[]; +}; export type AssetDeltaSyncDto = { updatedAfter: string; userIds: string[]; @@ -1092,6 +1144,9 @@ export type AssetFullSyncDto = { updatedUntil: string; userId?: string; }; +export type SyncStreamDto = { + types: SyncRequestType[]; +}; export type DatabaseBackupConfig = { cronExpression: string; enabled: boolean; @@ -1475,7 +1530,7 @@ export function restoreUserAdmin({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ - status: 201; + status: 200; data: UserAdminResponseDto; }>(`/admin/users/${encodeURIComponent(id)}/restore`, { ...opts, @@ -1703,7 +1758,7 @@ export function updateAssets({ assetBulkUpdateDto }: { }))); } /** - * Checks if assets exist by checksums + * checkBulkUpload */ export function checkBulkUpload({ assetBulkUploadCheckDto }: { assetBulkUploadCheckDto: AssetBulkUploadCheckDto; @@ -1718,7 +1773,7 @@ export function checkBulkUpload({ assetBulkUploadCheckDto }: { }))); } /** - * Get all asset of a device that are in the database, ID only. + * getAllUserAssetsByDeviceId */ export function getAllUserAssetsByDeviceId({ deviceId }: { deviceId: string; @@ -1731,7 +1786,7 @@ export function getAllUserAssetsByDeviceId({ deviceId }: { })); } /** - * Checks if multiple assets exist on the server and returns all existing - used by background backup + * checkExistingAssets */ export function checkExistingAssets({ checkExistingAssetsDto }: { checkExistingAssetsDto: CheckExistingAssetsDto; @@ -1839,7 +1894,7 @@ export function downloadAsset({ id, key }: { })); } /** - * Replace the asset with new file, without changing its id + * replaceAsset */ export function replaceAsset({ id, key, assetMediaReplaceDto }: { id: string; @@ -1885,22 +1940,6 @@ export function playAssetVideo({ id, key }: { ...opts })); } -export function getAuditDeletes({ after, entityType, userId }: { - after: string; - entityType: EntityType; - userId?: string; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: AuditDeletesResponseDto; - }>(`/audit/deletes${QS.query(QS.explode({ - after, - entityType, - userId - }))}`, { - ...opts - })); -} export function signUpAdmin({ signUpDto }: { signUpDto: SignUpDto; }, opts?: Oazapfts.RequestOpts) { @@ -2005,6 +2044,25 @@ export function getFaces({ id }: { ...opts })); } +export function createFace({ assetFaceCreateDto }: { + assetFaceCreateDto: AssetFaceCreateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/faces", oazapfts.json({ + ...opts, + method: "POST", + body: assetFaceCreateDto + }))); +} +export function deleteFace({ id, assetFaceDeleteDto }: { + id: string; + assetFaceDeleteDto: AssetFaceDeleteDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/faces/${encodeURIComponent(id)}`, oazapfts.json({ + ...opts, + method: "DELETE", + body: assetFaceDeleteDto + }))); +} export function reassignFacesById({ id, faceDto }: { id: string; faceDto: FaceDto; @@ -2166,11 +2224,21 @@ export function reverseGeocode({ lat, lon }: { ...opts })); } -export function searchMemories(opts?: Oazapfts.RequestOpts) { +export function searchMemories({ $for, isSaved, isTrashed, $type }: { + $for?: string; + isSaved?: boolean; + isTrashed?: boolean; + $type?: MemoryType; +}, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: MemoryResponseDto[]; - }>("/memories", { + }>(`/memories${QS.query(QS.explode({ + "for": $for, + isSaved, + isTrashed, + "type": $type + }))}`, { ...opts })); } @@ -2734,11 +2802,15 @@ export function deleteSession({ id }: { method: "DELETE" })); } -export function getAllSharedLinks(opts?: Oazapfts.RequestOpts) { +export function getAllSharedLinks({ albumId }: { + albumId?: string; +}, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: SharedLinkResponseDto[]; - }>("/shared-links", { + }>(`/shared-links${QS.query(QS.explode({ + albumId + }))}`, { ...opts })); } @@ -2897,6 +2969,32 @@ export function updateStack({ id, stackUpdateDto }: { body: stackUpdateDto }))); } +export function deleteSyncAck({ syncAckDeleteDto }: { + syncAckDeleteDto: SyncAckDeleteDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/sync/ack", oazapfts.json({ + ...opts, + method: "DELETE", + body: syncAckDeleteDto + }))); +} +export function getSyncAck(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: SyncAckDto[]; + }>("/sync/ack", { + ...opts + })); +} +export function sendSyncAck({ syncAckSetDto }: { + syncAckSetDto: SyncAckSetDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/sync/ack", oazapfts.json({ + ...opts, + method: "POST", + body: syncAckSetDto + }))); +} export function getDeltaSync({ assetDeltaSyncDto }: { assetDeltaSyncDto: AssetDeltaSyncDto; }, opts?: Oazapfts.RequestOpts) { @@ -2921,6 +3019,15 @@ export function getFullSyncForUser({ assetFullSyncDto }: { body: assetFullSyncDto }))); } +export function getSyncStream({ syncStreamDto }: { + syncStreamDto: SyncStreamDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/sync/stream", oazapfts.json({ + ...opts, + method: "POST", + body: syncStreamDto + }))); +} export function getConfig(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -3346,7 +3453,8 @@ export enum AlbumUserRole { } export enum SourceType { MachineLearning = "machine-learning", - Exif = "exif" + Exif = "exif", + Manual = "manual" } export enum AssetTypeEnum { Image = "IMAGE", @@ -3467,14 +3575,13 @@ export enum AssetMediaSize { Preview = "preview", Thumbnail = "thumbnail" } -export enum EntityType { - Asset = "ASSET", - Album = "ALBUM" -} export enum ManualJobName { PersonCleanup = "person-cleanup", TagCleanup = "tag-cleanup", - UserCleanup = "user-cleanup" + UserCleanup = "user-cleanup", + MemoryCleanup = "memory-cleanup", + MemoryCreate = "memory-create", + BackupDatabase = "backup-database" } export enum JobName { ThumbnailGeneration = "thumbnailGeneration", @@ -3537,6 +3644,26 @@ export enum Error2 { NoPermission = "no_permission", NotFound = "not_found" } +export enum SyncEntityType { + UserV1 = "UserV1", + UserDeleteV1 = "UserDeleteV1", + PartnerV1 = "PartnerV1", + PartnerDeleteV1 = "PartnerDeleteV1", + AssetV1 = "AssetV1", + AssetDeleteV1 = "AssetDeleteV1", + AssetExifV1 = "AssetExifV1", + PartnerAssetV1 = "PartnerAssetV1", + PartnerAssetDeleteV1 = "PartnerAssetDeleteV1", + PartnerAssetExifV1 = "PartnerAssetExifV1" +} +export enum SyncRequestType { + UsersV1 = "UsersV1", + PartnersV1 = "PartnersV1", + AssetsV1 = "AssetsV1", + AssetExifsV1 = "AssetExifsV1", + PartnerAssetsV1 = "PartnerAssetsV1", + PartnerAssetExifsV1 = "PartnerAssetExifsV1" +} export enum TranscodeHWAccel { Nvenc = "nvenc", Qsv = "qsv", diff --git a/readme_i18n/README_ar_JO.md b/readme_i18n/README_ar_JO.md index 32b3dbb9ec..90936cdefa 100644 --- a/readme_i18n/README_ar_JO.md +++ b/readme_i18n/README_ar_JO.md @@ -65,7 +65,7 @@ https://immich.app https://demo.immich.app بالنسبة لتطبيق الهاتف المحمول، يمكنك استخدام -`https://demo.immich.app/api` +`https://demo.immich.app` ل `نقطة نهاية الخادم` ```bash title="Demo Credential" diff --git a/readme_i18n/README_ca_ES.md b/readme_i18n/README_ca_ES.md index 3edfb951b1..5daa12c477 100644 --- a/readme_i18n/README_ca_ES.md +++ b/readme_i18n/README_ca_ES.md @@ -60,7 +60,7 @@ Podeu trobar la documentació principal, incloent les guies d'instal·lació, a Podeu accedir a la demostració web a https://demo.immich.app -Per a l'aplicació mòbil, podeu utilitzar `https://demo.immich.app/api` com a "URL de punt final del servidor". +Per a l'aplicació mòbil, podeu utilitzar `https://demo.immich.app` com a "URL de punt final del servidor". ```bash title="Credencials de la demo" Les credencials diff --git a/readme_i18n/README_de_DE.md b/readme_i18n/README_de_DE.md index 54ba3cc86e..3df41ad28f 100644 --- a/readme_i18n/README_de_DE.md +++ b/readme_i18n/README_de_DE.md @@ -64,7 +64,7 @@ Die Web-Demo kannst Du unter https://demo.immich.app finden. Die Demo läuft auf einer Free Tier Oracle VM in Amsterdam mit einer 2.4Ghz Quad-Core ARM64 CPU und 24GB RAM. -Für die Handy-App kannst Du `https://demo.immich.app/api` als `Server Endpoint URL` angeben. +Für die Handy-App kannst Du `https://demo.immich.app` als `Server Endpoint URL` angeben. ### Login Daten diff --git a/readme_i18n/README_es_ES.md b/readme_i18n/README_es_ES.md index fbc7eae16f..73e9a2a14b 100644 --- a/readme_i18n/README_es_ES.md +++ b/readme_i18n/README_es_ES.md @@ -61,7 +61,7 @@ Puedes encontrar la documentación oficial, incluidas las guías de instalación Puedes acceder a la demostración web en -Para la aplicación móvil, puedes usar `https://demo.immich.app/api` en la `URL del servidor`. +Para la aplicación móvil, puedes usar `https://demo.immich.app` en la `URL del servidor`. ```bash title="Credenciales de la demo" Credenciales diff --git a/readme_i18n/README_fr_FR.md b/readme_i18n/README_fr_FR.md index 1c6cc4e2c5..100bc219cb 100644 --- a/readme_i18n/README_fr_FR.md +++ b/readme_i18n/README_fr_FR.md @@ -61,7 +61,7 @@ Vous pouvez trouver la documentation principale ainsi que les guides d'installat Vous pouvez accéder à la démo en ligne sur https://demo.immich.app -Pour l'application mobile, vous pouvez utiliser `https://demo.immich.app/api` dans le champ `URL du point d'accès au serveur` +Pour l'application mobile, vous pouvez utiliser `https://demo.immich.app` dans le champ `URL du point d'accès au serveur` ```bash title="Identifiants pour la démo" Les identifiants diff --git a/readme_i18n/README_it_IT.md b/readme_i18n/README_it_IT.md index 0c34f22d4a..b1aab7ea95 100644 --- a/readme_i18n/README_it_IT.md +++ b/readme_i18n/README_it_IT.md @@ -61,7 +61,7 @@ La documentazione ufficiale, inclusa la guida all'installazione, è disponibile Prova la demo del progetto https://demo.immich.app -Sull'app mobile, imposta `https://demo.immich.app/api` come `Server Endpoint URL` +Sull'app mobile, imposta `https://demo.immich.app` come `Server Endpoint URL` ```bash title="Demo Credential" Credenziali di accesso diff --git a/readme_i18n/README_ja_JP.md b/readme_i18n/README_ja_JP.md index 828afa9812..328cba431c 100644 --- a/readme_i18n/README_ja_JP.md +++ b/readme_i18n/README_ja_JP.md @@ -60,7 +60,7 @@ web デモは https://demo.immich.app からアクセスできます -モバイルアプリの場合、`Server Endpoint URL` には `https://demo.immich.app/api` を使用することができます +モバイルアプリの場合、`Server Endpoint URL` には `https://demo.immich.app` を使用することができます ```bash title="Demo Credential" The credential diff --git a/readme_i18n/README_ko_KR.md b/readme_i18n/README_ko_KR.md index 844923a9bb..b4b0841ed5 100644 --- a/readme_i18n/README_ko_KR.md +++ b/readme_i18n/README_ko_KR.md @@ -63,7 +63,7 @@ [이곳](https://demo.immich.app)에서 데모를 체험해보세요. 데모 서버는 2.4Ghz 쿼드 코어 ARM64 CPU 및 24GB 램으로 구성된 Oracle Free-tier VM 암스테르담 리전에서 구동됩니다. -모바일 앱의 경우, `서버 엔드포인트 URL`에 `https://demo.immich.app/api`를 입력하세요. +모바일 앱의 경우, `서버 엔드포인트 URL`에 `https://demo.immich.app`를 입력하세요. ### 로그인 정보 diff --git a/readme_i18n/README_nl_NL.md b/readme_i18n/README_nl_NL.md index 53f611f848..b67b66aa7d 100644 --- a/readme_i18n/README_nl_NL.md +++ b/readme_i18n/README_nl_NL.md @@ -61,7 +61,7 @@ De belangrijkste documentatie, inclusief installatie handleidingen, zijn te vind Je kunt de demo [hier](https://demo.immich.app/) bekijken. De demo server is actief op een Free-tier Oracle VM in Amsterdam met een 2.4GHz quad-core ARM64 CPU en 24GB RAM. -Voor de mobiele app kun je gebruik maken van `https://demo.immich.app/api` voor de `Server Endpoint URL` +Voor de mobiele app kun je gebruik maken van `https://demo.immich.app` voor de `Server Endpoint URL` ### Login gegevens diff --git a/readme_i18n/README_pt_BR.md b/readme_i18n/README_pt_BR.md index 2bb8995603..7df3af91e4 100644 --- a/readme_i18n/README_pt_BR.md +++ b/readme_i18n/README_pt_BR.md @@ -71,7 +71,7 @@ hospedada no Nível Gratuito da Oracle VM em Amsterdam com um processador 2.4Ghz quad-core ARM64 e 24GB de RAM. No aplicativo para dispositivos móveis, você pode usar -`https://demo.immich.app/api` no campo `Server Endpoint URL` +`https://demo.immich.app` no campo `Server Endpoint URL` ### Credenciais de login diff --git a/readme_i18n/README_ru_RU.md b/readme_i18n/README_ru_RU.md index 36ea03a3be..11f1dfdc4b 100644 --- a/readme_i18n/README_ru_RU.md +++ b/readme_i18n/README_ru_RU.md @@ -64,7 +64,7 @@ Вы можете опробовать [Web демонстрационную версию](https://demo.immich.app/) -В мобильном приложении укажите `https://demo.immich.app/api` в поле `URL-адрес сервера` +В мобильном приложении укажите `https://demo.immich.app` в поле `URL-адрес сервера` ### Данные для входа diff --git a/readme_i18n/README_sv_SE.md b/readme_i18n/README_sv_SE.md index 69f23fc482..634de591a6 100644 --- a/readme_i18n/README_sv_SE.md +++ b/readme_i18n/README_sv_SE.md @@ -62,7 +62,7 @@ Dokumentation och installationsguider hittas på https://imiich.app/. Ett webb-demo finns att testa på https://demo.immich.app -Använd `https://demo.immich.app/api` i mobilappen som `Server Endpoint URL` +Använd `https://demo.immich.app` i mobilappen som `Server Endpoint URL` ```bash title="Inloggningsuppgifter För Demo" Inloggsningsuppgifter diff --git a/readme_i18n/README_th_TH.md b/readme_i18n/README_th_TH.md index 5a73251652..20a82a5d81 100644 --- a/readme_i18n/README_th_TH.md +++ b/readme_i18n/README_th_TH.md @@ -40,12 +40,12 @@ Tiếng Việt

-## ข้อจำกัดความรับผิดชอบ +## ข้อควรระวัง -- ⚠️ โพรเจกต์นี้กำลังอยู่ระหว่างการพัฒนา**ที่มีการเปลี่ยนแปลงบ่อยมาก** -- ⚠️ คาดว่าจะมีข้อผิดพลาดและการเปลี่ยนแปลงที่ส่งผลเสีย -- ⚠️ **ห้ามใช้แอปนี้เป็นวิธีการเดียวในการจัดเก็บภาพถ่ายและวิดีโอของคุณ** -- ⚠️ ปฏิบัติตามแผนการสำรองข้อมูลแบบ [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) สำหรับภาพถ่ายและวิดีโอที่สำคัญของคุณอยู่เสมอ! +- ⚠️ โพรเจกต์นี้กำลังอยู่ระหว่างการพัฒนา**มีการเปลี่ยนแปลงบ่อยมาก** +- ⚠️ อาจจะเกิดข้อผิดพลาดและการเปลี่ยนแปลงที่ส่งผลเสีย +- ⚠️ **ห้ามใช้ระบบนี้เป็นวิธีการเดียวในการจัดเก็บภาพถ่ายและวิดีโอของคุณ** +- ⚠️ ปฏิบัติตามแผนการสำรองข้อมูลแบบ [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) สำหรับภาพถ่ายและวิดีโอที่สำคัญของคุณอยู่เสมอ > [!NOTE] > คุณสามารถหาคู่มือหลัก รวมถึงคู่มือการติดตั้ง ได้ที่ https://immich.app/ @@ -65,7 +65,7 @@ เข้าถึงการสาธิตได้ [ที่นี่](https://demo.immich.app) โดยการสาธิตนี้ทำงานบน Oracle VM Free-tier ตั้งอยู่ที่อัมสเตอร์ดัม ใช้ซีพียู ARM64 quad-core 2.4Ghz และแรม 24GB -สำหรับแอปมือถือ คุณสามารถใช้ `https://demo.immich.app/api` เป็น `Server Endpoint URL` +สำหรับแอปมือถือ คุณสามารถใช้ `https://demo.immich.app` เป็น `Server Endpoint URL` ### ข้อมูลการเข้าสู่ระบบ @@ -79,15 +79,15 @@ | :----------------------------------------- | ------ | ------ | | อัปโหลดและดูวิดีโอและภาพถ่าย | ใช่ | ใช่ | | การสำรองข้อมูลอัตโนมัติเมื่อเปิดแอป | ใช่ | N/A | -| ป้องกันการซ้ำซ้อนของไฟล์ | ใช่ | ใช่ | +| ป้องกันการซ้ำของไฟล์ | ใช่ | ใช่ | | เลือกอัลบั้มสำหรับสำรองข้อมูล | ใช่ | N/A | | ดาวน์โหลดภาพถ่ายและวิดีโอไปยังอุปกรณ์ | ใช่ | ใช่ | | รองรับผู้ใช้หลายคน | ใช่ | ใช่ | | อัลบั้มและอัลบั้มแชร์ | ใช่ | ใช่ | | แถบเลื่อนแบบลากได้ | ใช่ | ใช่ | | รองรับรูปแบบไฟล์ RAW | ใช่ | ใช่ | -| ดูข้อมูลเมตา (EXIF, แผนที่) | ใช่ | ใช่ | -| ค้นหาจากข้อมูลเมตา วัตถุ ใบหน้า และ CLIP | ใช่ | ใช่ | +| ดูข้อมูลเมตาดาต้า (EXIF, แผนที่) | ใช่ | ใช่ | +| ค้นหาจากข้อมูลเมตาดาต้า วัตถุ ใบหน้า และ CLIP | ใช่ | ใช่ | | ฟังก์ชันการจัดการผู้ดูแลระบบ | ไม่ใช่ | ใช่ | | การสำรองข้อมูลพื้นหลัง | ใช่ | N/A | | การเลื่อนแบบเสมือน | ใช่ | ใช่ | @@ -100,7 +100,7 @@ | การจัดเก็บและรายการโปรด | ใช่ | ใช่ | | แผนที่ทั่วโลก | ใช่ | ใช่ | | การแชร์กับคู่หู | ใช่ | ใช่ | -| การจดจำใบหน้าและการจัดกลุ่ม | ใช่ | ใช่ | +| ระบบจดจำใบหน้าและการจัดกลุ่ม | ใช่ | ใช่ | | ความทรงจำ (x ปีที่แล้ว) | ใช่ | ใช่ | | รองรับแบบออฟไลน์ | ใช่ | ไม่ใช่ | | แกลเลอรีแบบอ่านอย่างเดียว | ใช่ | ใช่ | @@ -108,13 +108,13 @@ ## การแปลภาษา -อ่านเพิ่มเติมเกี่ยวกับการแปลภาษา [ที่นี่](https://immich.app/docs/developer/translations) +อ่านเพิ่มเติมเกี่ยวกับการแปล [ที่นี่](https://immich.app/docs/developer/translations) สถานะการแปล -## กิจกรรมของคลังเก็บข้อมูล +## กิจกรรมของ Repository ![กิจกรรม](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "ภาพการวิเคราะห์ของ Repobeats") diff --git a/readme_i18n/README_tr_TR.md b/readme_i18n/README_tr_TR.md index 88c2cea11c..fd52a4425c 100644 --- a/readme_i18n/README_tr_TR.md +++ b/readme_i18n/README_tr_TR.md @@ -60,7 +60,7 @@ Kurulum dahil olmak üzere resmi belgeleri https://immich.app/ adresinde bulabil Web demo adresi: https://demo.immich.app -Mobil uygulama için `Server Endpoint URL` olarak `https://demo.immich.app/api` adresini kullanabilirsiniz. +Mobil uygulama için `Server Endpoint URL` olarak `https://demo.immich.app` adresini kullanabilirsiniz. ```bash title="Demo Bilgileri" Giriş bilgileri: diff --git a/readme_i18n/README_uk_UA.md b/readme_i18n/README_uk_UA.md index 9a5c808863..13cafd5ad3 100644 --- a/readme_i18n/README_uk_UA.md +++ b/readme_i18n/README_uk_UA.md @@ -63,7 +63,7 @@ Доступ до демо-версії [тут](https://demo.immich.app). Демоверсія працює на безкоштовному Oracle VM у Амстердамі з чотириядерним ARM64 процесором (2.4 ГГц) і 24 ГБ оперативної пам’яті. -Для мобільного додатку ви можете використовувати `https://demo.immich.app/api` в якості `Server Endpoint URL`. +Для мобільного додатку ви можете використовувати `https://demo.immich.app` в якості `Server Endpoint URL`. ### Облікові дані для входу diff --git a/readme_i18n/README_vi_VN.md b/readme_i18n/README_vi_VN.md index 83ccdcafd8..c25ed4c92a 100644 --- a/readme_i18n/README_vi_VN.md +++ b/readme_i18n/README_vi_VN.md @@ -65,7 +65,7 @@ Truy cập bản demo [tại đây](https://demo.immich.app). Bản demo đang chạy trên máy ảo Oracle Free-tier ở Amsterdam với CPU ARM64 lõi tứ 2,4 GHz và RAM 24 GB. -Đối với ứng dụng di động, bạn có thể sử dụng `https://demo.immich.app/api` cho `Server Endpoint URL` +Đối với ứng dụng di động, bạn có thể sử dụng `https://demo.immich.app` cho `Server Endpoint URL` ### Thông tin đăng nhập diff --git a/readme_i18n/README_zh_CN.md b/readme_i18n/README_zh_CN.md index 8cb1f0c5d1..83fbe59a68 100644 --- a/readme_i18n/README_zh_CN.md +++ b/readme_i18n/README_zh_CN.md @@ -67,7 +67,7 @@ 您可以在[此处](https://demo.immich.app)访问在线演示网站。该示例网站运行的机器配置为:甲骨文免费虚拟机套餐——阿姆斯特丹 4核 2.4Ghz ARM64 CPU,24GB RAM。 -在移动端,您可以使用 `https://demo.immich.app/api` 作为 `服务终端链接` +在移动端,您可以使用 `https://demo.immich.app` 作为 `服务终端链接` ### 登录认证信息 diff --git a/renovate.json b/renovate.json index 2634eaef4d..c65ad02754 100644 --- a/renovate.json +++ b/renovate.json @@ -4,6 +4,7 @@ "config:recommended", "docker:pinDigests" ], + "recreateWhen": "never", "minimumReleaseAge": "5 days", "packageRules": [ { diff --git a/server/.nvmrc b/server/.nvmrc index d5b283a3ac..7d41c735d7 100644 --- a/server/.nvmrc +++ b/server/.nvmrc @@ -1 +1 @@ -22.13.1 +22.14.0 diff --git a/server/Dockerfile b/server/Dockerfile index 9c3b5573c1..83e750a3f9 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:20250123@sha256:04eba5cd87d61bc3d20a3915b2302f04d08fbc329c55ee0cde103c502f59f412 AS dev +FROM ghcr.io/immich-app/base-server-dev:20250311@sha256:dd270c9fb535ea4d216241d6984e20ef4b4bc3514658a045e5a1c3821aa44507 AS dev RUN apt-get install --no-install-recommends -yqq tini WORKDIR /usr/src/app @@ -25,7 +25,7 @@ COPY --from=dev /usr/src/app/node_modules/@img ./node_modules/@img COPY --from=dev /usr/src/app/node_modules/exiftool-vendored.pl ./node_modules/exiftool-vendored.pl # web build -FROM node:22.13.1-alpine3.20@sha256:c52e20859a92b3eccbd3a36c5e1a90adc20617d8d421d65e8a622e87b5dac963 AS web +FROM node:22.14.0-alpine3.20@sha256:40be979442621049f40b1d51a26b55e281246b5de4e5f51a18da7beb6e17e3f9 AS web WORKDIR /usr/src/open-api/typescript-sdk COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./ @@ -42,7 +42,7 @@ RUN npm run build # prod build -FROM ghcr.io/immich-app/base-server-prod:20250123@sha256:591739983913f82672d8191258f3a1a24c123db0d619ff91fca8fef431ee1338 +FROM ghcr.io/immich-app/base-server-prod:20250311@sha256:8c96b3df2161806c9dd6032356ca04c28d374057462b3964c8e8d3cc35c035ee WORKDIR /usr/src/app ENV NODE_ENV=production \ diff --git a/server/README.md b/server/README.md deleted file mode 100644 index 61b6c25381..0000000000 --- a/server/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Immich server project - -This project uses the [NestJS](https://nestjs.com/) web framework. Please refer to [the NestJS docs](https://docs.nestjs.com/) for information on getting started as a contributor to this project. diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs index d29b6f7238..5fe62b9651 100644 --- a/server/eslint.config.mjs +++ b/server/eslint.config.mjs @@ -57,6 +57,7 @@ export default [ 'unicorn/no-thenable': 'off', 'unicorn/import-style': 'off', 'unicorn/prefer-structured-clone': 'off', + 'unicorn/no-for-loop': 'off', '@typescript-eslint/await-thenable': 'error', '@typescript-eslint/no-misused-promises': 'error', 'require-await': 'off', diff --git a/server/package-lock.json b/server/package-lock.json index 248792918d..fbb997da59 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,29 +1,28 @@ { "name": "immich", - "version": "1.125.1", + "version": "1.129.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich", - "version": "1.125.1", + "version": "1.129.0", "license": "GNU Affero General Public License version 3", "dependencies": { - "@nestjs/bullmq": "^11.0.0", - "@nestjs/common": "^10.2.2", - "@nestjs/core": "^10.2.2", + "@nestjs/bullmq": "^11.0.1", + "@nestjs/common": "^11.0.4", + "@nestjs/core": "^11.0.4", "@nestjs/event-emitter": "^3.0.0", - "@nestjs/platform-express": "^10.2.2", - "@nestjs/platform-socket.io": "^10.2.2", + "@nestjs/platform-express": "^11.0.4", + "@nestjs/platform-socket.io": "^11.0.4", "@nestjs/schedule": "^5.0.0", - "@nestjs/swagger": "^8.0.0", - "@nestjs/typeorm": "^10.0.0", - "@nestjs/websockets": "^10.2.2", - "@opentelemetry/auto-instrumentations-node": "^0.55.0", + "@nestjs/swagger": "^11.0.2", + "@nestjs/websockets": "^11.0.4", + "@opentelemetry/auto-instrumentations-node": "^0.56.0", "@opentelemetry/context-async-hooks": "^1.24.0", "@opentelemetry/exporter-prometheus": "^0.57.0", "@opentelemetry/sdk-node": "^0.57.0", - "@react-email/components": "^0.0.32", + "@react-email/components": "^0.0.33", "@socket.io/redis-adapter": "^8.3.0", "archiver": "^7.0.0", "async-lock": "^1.4.0", @@ -32,7 +31,8 @@ "chokidar": "^3.5.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", - "cookie-parser": "^1.4.6", + "cookie": "^1.0.2", + "cookie-parser": "^1.4.7", "exiftool-vendored": "^28.3.1", "fast-glob": "^3.3.2", "fluent-ffmpeg": "^2.1.2", @@ -46,9 +46,9 @@ "kysely-postgres-js": "^2.0.0", "lodash": "^4.17.21", "luxon": "^3.4.2", - "nest-commander": "^3.11.1", - "nestjs-cls": "^4.3.0", - "nestjs-kysely": "^1.0.0", + "nest-commander": "^3.16.0", + "nestjs-cls": "^5.0.0", + "nestjs-kysely": "^1.1.0", "nestjs-otel": "^6.0.0", "nodemailer": "^6.9.13", "openid-client": "^5.4.3", @@ -59,38 +59,41 @@ "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "sanitize-filename": "^1.6.3", + "sanitize-html": "^2.14.0", "semver": "^7.6.2", "sharp": "^0.33.0", "sirv": "^3.0.0", "tailwindcss-preset-email": "^1.3.2", "thumbhash": "^0.1.1", "typeorm": "^0.3.17", - "ua-parser-js": "^1.0.35", + "ua-parser-js": "^2.0.0", "validator": "^13.12.0" }, "devDependencies": { "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.8.0", - "@nestjs/cli": "^10.1.16", - "@nestjs/schematics": "^10.0.2", - "@nestjs/testing": "^10.2.2", + "@nestjs/cli": "^11.0.2", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.4", "@swc/core": "^1.4.14", "@testcontainers/postgresql": "^10.2.1", + "@testcontainers/redis": "^10.18.0", "@types/archiver": "^6.0.0", "@types/async-lock": "^1.4.2", "@types/bcrypt": "^5.0.0", - "@types/cookie-parser": "^1.4.3", + "@types/cookie-parser": "^1.4.8", "@types/express": "^4.17.17", "@types/fluent-ffmpeg": "^2.1.21", "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^22.10.9", + "@types/node": "^22.13.9", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", "@types/pngjs": "^6.0.5", "@types/react": "^19.0.0", + "@types/sanitize-html": "^2.13.0", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.0", "@types/ua-parser-js": "^0.7.36", @@ -101,8 +104,8 @@ "eslint-config-prettier": "^10.0.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^56.0.1", - "globals": "^15.9.0", - "kysely-codegen": "^0.17.0", + "globals": "^16.0.0", + "kysely-codegen": "^0.18.0", "mock-fs": "^5.2.0", "node-addon-api": "^8.3.0", "pngjs": "^7.0.0", @@ -111,6 +114,7 @@ "rimraf": "^6.0.0", "source-map-support": "^0.5.21", "sql-formatter": "^15.0.0", + "testcontainers": "^10.18.0", "tsconfig-paths": "^4.2.0", "typescript": "^5.3.3", "unplugin-swc": "^1.4.5", @@ -145,101 +149,36 @@ "node": ">=6.0.0" } }, - "node_modules/@angular-devkit/core": { - "version": "17.3.11", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.11.tgz", - "integrity": "sha512-vTNDYNsLIWpYk2I969LMQFH29GTsLzxNk/0cLw5q56ARF0v5sIWfHYwGTS88jdDqIpuuettcSczbxeA7EuAmqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "8.12.0", - "ajv-formats": "2.1.1", - "jsonc-parser": "3.2.1", - "picomatch": "4.0.1", - "rxjs": "7.8.1", - "source-map": "0.7.4" - }, - "engines": { - "node": "^18.13.0 || >=20.9.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "peerDependencies": { - "chokidar": "^3.5.2" - }, - "peerDependenciesMeta": { - "chokidar": { - "optional": true - } - } - }, - "node_modules/@angular-devkit/core/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@angular-devkit/core/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/@angular-devkit/core/node_modules/picomatch": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.1.tgz", - "integrity": "sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/@angular-devkit/schematics": { - "version": "17.3.11", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.3.11.tgz", - "integrity": "sha512-I5wviiIqiFwar9Pdk30Lujk8FczEEc18i22A5c6Z9lbmhPQdTroDnEQdsfXjy404wPe8H62s0I15o4pmMGfTYQ==", + "version": "19.1.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.1.8.tgz", + "integrity": "sha512-2JGUMD3zjfY8G4RYpypm2/1YEO+O4DtFycUvptIpsBYyULgnEbJ3tlp2oRiXI2vp9tC8IyWqa/swlA8DTI6ZYQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "17.3.11", - "jsonc-parser": "3.2.1", - "magic-string": "0.30.8", + "@angular-devkit/core": "19.1.8", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.17", "ora": "5.4.1", "rxjs": "7.8.1" }, "engines": { - "node": "^18.13.0 || >=20.9.0", + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" } }, "node_modules/@angular-devkit/schematics-cli": { - "version": "17.3.11", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-17.3.11.tgz", - "integrity": "sha512-kcOMqp+PHAKkqRad7Zd7PbpqJ0LqLaNZdY1+k66lLWmkEBozgq8v4ASn/puPWf9Bo0HpCiK+EzLf0VHE8Z/y6Q==", + "version": "19.1.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-19.1.8.tgz", + "integrity": "sha512-sHblN9EuiJgKwJVYc+nhpU+GlVkAJHJc7lBR8YSoaugNGcCMkWn4f7rJnJDywL/CEOHBICnyWZKfTCMsMyg1Cw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "17.3.11", - "@angular-devkit/schematics": "17.3.11", + "@angular-devkit/core": "19.1.8", + "@angular-devkit/schematics": "19.1.8", + "@inquirer/prompts": "7.2.1", "ansi-colors": "4.1.3", - "inquirer": "9.2.15", "symbol-observable": "4.0.0", "yargs-parser": "21.1.1" }, @@ -247,79 +186,246 @@ "schematics": "bin/schematics.js" }, "engines": { - "node": "^18.13.0 || >=20.9.0", + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" } }, - "node_modules/@angular-devkit/schematics-cli/node_modules/chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@angular-devkit/schematics-cli/node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, - "node_modules/@angular-devkit/schematics-cli/node_modules/inquirer": { - "version": "9.2.15", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-9.2.15.tgz", - "integrity": "sha512-vI2w4zl/mDluHt9YEQ/543VTCwPKWiHzKtm9dM2V0NdFcqEexDAjUHzO1oA60HRNaVifGXXM1tRRNluLVHa0Kg==", + "node_modules/@angular-devkit/schematics-cli/node_modules/@angular-devkit/core": { + "version": "19.1.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.1.8.tgz", + "integrity": "sha512-j1zHKvOsGwu5YwAZGuzi835R9vcW7PkfxmSRIJeVl+vawgk31K3zFb4UPH8AY/NPWYqXIAnwpka3HC1+JrWLWA==", "dev": true, "license": "MIT", "dependencies": { - "@ljharb/through": "^2.3.12", - "ansi-escapes": "^4.3.2", - "chalk": "^5.3.0", - "cli-cursor": "^3.1.0", - "cli-width": "^4.1.0", - "external-editor": "^3.1.0", - "figures": "^3.2.0", - "lodash": "^4.17.21", - "mute-stream": "1.0.0", - "ora": "^5.4.1", - "run-async": "^3.0.0", - "rxjs": "^7.8.1", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^6.2.0" + "ajv": "8.17.1", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.2", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/prompts": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.2.1.tgz", + "integrity": "sha512-v2JSGri6/HXSfoGIwuKEn8sNCQK6nsB2BNpy2lSX6QH9bsECrMv93QHnj5+f+1ZWpF/VNioIV2B/PDox8EvGuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.0.4", + "@inquirer/confirm": "^5.1.1", + "@inquirer/editor": "^4.2.1", + "@inquirer/expand": "^4.0.4", + "@inquirer/input": "^4.1.1", + "@inquirer/number": "^3.0.4", + "@inquirer/password": "^4.0.4", + "@inquirer/rawlist": "^4.0.4", + "@inquirer/search": "^3.0.4", + "@inquirer/select": "^4.0.4" }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" } }, - "node_modules/@angular-devkit/schematics-cli/node_modules/mute-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", - "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/@angular-devkit/schematics-cli/node_modules/run-async": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz", - "integrity": "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==", + "node_modules/@angular-devkit/schematics-cli/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "readdirp": "^4.0.1" + }, "engines": { - "node": ">=0.12.0" + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@angular-devkit/schematics/node_modules/@angular-devkit/core": { + "version": "19.1.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.1.8.tgz", + "integrity": "sha512-j1zHKvOsGwu5YwAZGuzi835R9vcW7PkfxmSRIJeVl+vawgk31K3zFb4UPH8AY/NPWYqXIAnwpka3HC1+JrWLWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.17.1", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.2", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@angular-devkit/schematics/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@angular-devkit/schematics/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular-devkit/schematics/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@angular-devkit/schematics/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-devkit/schematics/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" } }, "node_modules/@babel/code-frame": { @@ -337,9 +443,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.3.tgz", - "integrity": "sha512-nHIxvKPniQXpmQLb0vhY3VaFb3S0YrTAwpOWJZh1wn3oJPjJk9Asva204PsBdmAE8vpzfHudT8DB0scYvy9q0g==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", + "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -385,13 +491,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.3.tgz", - "integrity": "sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.9.tgz", + "integrity": "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.26.3", - "@babel/types": "^7.26.3", + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" @@ -401,12 +507,12 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz", - "integrity": "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==", + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz", + "integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.25.9", + "@babel/compat-data": "^7.26.5", "@babel/helper-validator-option": "^7.25.9", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -498,25 +604,25 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", - "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.9.tgz", + "integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==", "license": "MIT", "dependencies": { - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.0" + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz", - "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", + "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", "license": "MIT", "dependencies": { - "@babel/types": "^7.26.3" + "@babel/types": "^7.26.9" }, "bin": { "parser": "bin/babel-parser.js" @@ -526,30 +632,30 @@ } }, "node_modules/@babel/template": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", - "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", + "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.25.9", - "@babel/parser": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.26.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.4.tgz", - "integrity": "sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.9.tgz", + "integrity": "sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.3", - "@babel/parser": "^7.26.3", - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.3", + "@babel/generator": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.9", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -567,9 +673,9 @@ } }, "node_modules/@babel/types": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz", - "integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", + "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.25.9", @@ -618,9 +724,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.11.tgz", - "integrity": "sha512-FnzU0LyE3ySQk7UntJO4+qIiQgI7KoODnZg5xzXIrFJlKd2P2gwHsHY4927xj9y5PJmJSzULiUCWmv7iWnNa7g==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.0.tgz", + "integrity": "sha512-3sG8Zwa5fMcA9bgqB8AfWPQ+HFke6uD3h1s3RIwUNK8EG7a4buxvuFTs3j1IMs2NXAk9F30C/FF4vxRgQCcmoQ==", "cpu": [ "ppc64" ], @@ -630,13 +736,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.11.tgz", - "integrity": "sha512-5OVapq0ClabvKvQ58Bws8+wkLCV+Rxg7tUVbo9xu034Nm536QTII4YzhaFriQ7rMrorfnFKUsArD2lqKbFY4vw==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.0.tgz", + "integrity": "sha512-+KuOHTKKyIKgEEqKbGTK8W7mPp+hKinbMBeEnNzjJGyFcWsfrXjSTNluJHCY1RqhxFurdD8uNXQDei7qDlR6+g==", "cpu": [ "arm" ], @@ -646,13 +752,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.11.tgz", - "integrity": "sha512-aiu7K/5JnLj//KOnOfEZ0D90obUkRzDMyqd/wNAUQ34m4YUPVhRZpnqKV9uqDGxT7cToSDnIHsGooyIczu9T+Q==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.0.tgz", + "integrity": "sha512-EuHFUYkAVfU4qBdyivULuu03FhJO4IJN9PGuABGrFy4vUuzk91P2d+npxHcFdpUnfYKy0PuV+n6bKIpHOB3prQ==", "cpu": [ "arm64" ], @@ -662,13 +768,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.11.tgz", - "integrity": "sha512-eccxjlfGw43WYoY9QgB82SgGgDbibcqyDTlk3l3C0jOVHKxrjdc9CTwDUQd0vkvYg5um0OH+GpxYvp39r+IPOg==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.0.tgz", + "integrity": "sha512-WRrmKidLoKDl56LsbBMhzTTBxrsVwTKdNbKDalbEZr0tcsBgCLbEtoNthOW6PX942YiYq8HzEnb4yWQMLQuipQ==", "cpu": [ "x64" ], @@ -678,13 +784,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.11.tgz", - "integrity": "sha512-ETp87DRWuSt9KdDVkqSoKoLFHYTrkyz2+65fj9nfXsaV3bMhTCjtQfw3y+um88vGRKRiF7erPrh/ZuIdLUIVxQ==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.0.tgz", + "integrity": "sha512-YLntie/IdS31H54Ogdn+v50NuoWF5BDkEUFpiOChVa9UnKpftgwzZRrI4J132ETIi+D8n6xh9IviFV3eXdxfow==", "cpu": [ "arm64" ], @@ -694,13 +800,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.11.tgz", - "integrity": "sha512-fkFUiS6IUK9WYUO/+22omwetaSNl5/A8giXvQlcinLIjVkxwTLSktbF5f/kJMftM2MJp9+fXqZ5ezS7+SALp4g==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.0.tgz", + "integrity": "sha512-IMQ6eme4AfznElesHUPDZ+teuGwoRmVuuixu7sv92ZkdQcPbsNHzutd+rAfaBKo8YK3IrBEi9SLLKWJdEvJniQ==", "cpu": [ "x64" ], @@ -710,13 +816,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.11.tgz", - "integrity": "sha512-lhoSp5K6bxKRNdXUtHoNc5HhbXVCS8V0iZmDvyWvYq9S5WSfTIHU2UGjcGt7UeS6iEYp9eeymIl5mJBn0yiuxA==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.0.tgz", + "integrity": "sha512-0muYWCng5vqaxobq6LB3YNtevDFSAZGlgtLoAc81PjUfiFz36n4KMpwhtAd4he8ToSI3TGyuhyx5xmiWNYZFyw==", "cpu": [ "arm64" ], @@ -726,13 +832,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.11.tgz", - "integrity": "sha512-JkUqn44AffGXitVI6/AbQdoYAq0TEullFdqcMY/PCUZ36xJ9ZJRtQabzMA+Vi7r78+25ZIBosLTOKnUXBSi1Kw==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.0.tgz", + "integrity": "sha512-XKDVu8IsD0/q3foBzsXGt/KjD/yTKBCIwOHE1XwiXmrRwrX6Hbnd5Eqn/WvDekddK21tfszBSrE/WMaZh+1buQ==", "cpu": [ "x64" ], @@ -742,13 +848,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.11.tgz", - "integrity": "sha512-3CRkr9+vCV2XJbjwgzjPtO8T0SZUmRZla+UL1jw+XqHZPkPgZiyWvbDvl9rqAN8Zl7qJF0O/9ycMtjU67HN9/Q==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.0.tgz", + "integrity": "sha512-SEELSTEtOFu5LPykzA395Mc+54RMg1EUgXP+iw2SJ72+ooMwVsgfuwXo5Fn0wXNgWZsTVHwY2cg4Vi/bOD88qw==", "cpu": [ "arm" ], @@ -758,13 +864,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.11.tgz", - "integrity": "sha512-LneLg3ypEeveBSMuoa0kwMpCGmpu8XQUh+mL8XXwoYZ6Be2qBnVtcDI5azSvh7vioMDhoJFZzp9GWp9IWpYoUg==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.0.tgz", + "integrity": "sha512-j1t5iG8jE7BhonbsEg5d9qOYcVZv/Rv6tghaXM/Ug9xahM0nX/H2gfu6X6z11QRTMT6+aywOMA8TDkhPo8aCGw==", "cpu": [ "arm64" ], @@ -774,13 +880,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.11.tgz", - "integrity": "sha512-caHy++CsD8Bgq2V5CodbJjFPEiDPq8JJmBdeyZ8GWVQMjRD0sU548nNdwPNvKjVpamYYVL40AORekgfIubwHoA==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.0.tgz", + "integrity": "sha512-P7O5Tkh2NbgIm2R6x1zGJJsnacDzTFcRWZyTTMgFdVit6E98LTxO+v8LCCLWRvPrjdzXHx9FEOA8oAZPyApWUA==", "cpu": [ "ia32" ], @@ -790,13 +896,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.11.tgz", - "integrity": "sha512-ppZSSLVpPrwHccvC6nQVZaSHlFsvCQyjnvirnVjbKSHuE5N24Yl8F3UwYUUR1UEPaFObGD2tSvVKbvR+uT1Nrg==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.0.tgz", + "integrity": "sha512-InQwepswq6urikQiIC/kkx412fqUZudBO4SYKu0N+tGhXRWUqAx+Q+341tFV6QdBifpjYgUndV1hhMq3WeJi7A==", "cpu": [ "loong64" ], @@ -806,13 +912,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.11.tgz", - "integrity": "sha512-B5x9j0OgjG+v1dF2DkH34lr+7Gmv0kzX6/V0afF41FkPMMqaQ77pH7CrhWeR22aEeHKaeZVtZ6yFwlxOKPVFyg==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.0.tgz", + "integrity": "sha512-J9rflLtqdYrxHv2FqXE2i1ELgNjT+JFURt/uDMoPQLcjWQA5wDKgQA4t/dTqGa88ZVECKaD0TctwsUfHbVoi4w==", "cpu": [ "mips64el" ], @@ -822,13 +928,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.11.tgz", - "integrity": "sha512-MHrZYLeCG8vXblMetWyttkdVRjQlQUb/oMgBNurVEnhj4YWOr4G5lmBfZjHYQHHN0g6yDmCAQRR8MUHldvvRDA==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.0.tgz", + "integrity": "sha512-cShCXtEOVc5GxU0fM+dsFD10qZ5UpcQ8AM22bYj0u/yaAykWnqXJDpd77ublcX6vdDsWLuweeuSNZk4yUxZwtw==", "cpu": [ "ppc64" ], @@ -838,13 +944,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.11.tgz", - "integrity": "sha512-f3DY++t94uVg141dozDu4CCUkYW+09rWtaWfnb3bqe4w5NqmZd6nPVBm+qbz7WaHZCoqXqHz5p6CM6qv3qnSSQ==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.0.tgz", + "integrity": "sha512-HEtaN7Y5UB4tZPeQmgz/UhzoEyYftbMXrBCUjINGjh3uil+rB/QzzpMshz3cNUxqXN7Vr93zzVtpIDL99t9aRw==", "cpu": [ "riscv64" ], @@ -854,13 +960,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.11.tgz", - "integrity": "sha512-A5xdUoyWJHMMlcSMcPGVLzYzpcY8QP1RtYzX5/bS4dvjBGVxdhuiYyFwp7z74ocV7WDc0n1harxmpq2ePOjI0Q==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.0.tgz", + "integrity": "sha512-WDi3+NVAuyjg/Wxi+o5KPqRbZY0QhI9TjrEEm+8dmpY9Xir8+HE/HNx2JoLckhKbFopW0RdO2D72w8trZOV+Wg==", "cpu": [ "s390x" ], @@ -870,13 +976,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.11.tgz", - "integrity": "sha512-grbyMlVCvJSfxFQUndw5mCtWs5LO1gUlwP4CDi4iJBbVpZcqLVT29FxgGuBJGSzyOxotFG4LoO5X+M1350zmPA==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.0.tgz", + "integrity": "sha512-a3pMQhUEJkITgAw6e0bWA+F+vFtCciMjW/LPtoj99MhVt+Mfb6bbL9hu2wmTZgNd994qTAEw+U/r6k3qHWWaOQ==", "cpu": [ "x64" ], @@ -886,13 +992,30 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", + "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.11.tgz", - "integrity": "sha512-13jvrQZJc3P230OhU8xgwUnDeuC/9egsjTkXN49b3GcS5BKvJqZn86aGM8W9pd14Kd+u7HuFBMVtrNGhh6fHEQ==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.0.tgz", + "integrity": "sha512-cRK+YDem7lFTs2Q5nEv/HHc4LnrfBCbH5+JHu6wm2eP+d8OZNoSMYgPZJq78vqQ9g+9+nMuIsAO7skzphRXHyw==", "cpu": [ "x64" ], @@ -902,13 +1025,29 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.0.tgz", + "integrity": "sha512-suXjq53gERueVWu0OKxzWqk7NxiUWSUlrxoZK7usiF50C6ipColGR5qie2496iKGYNLhDZkPxBI3erbnYkU0rQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.11.tgz", - "integrity": "sha512-ysyOGZuTp6SNKPE11INDUeFVVQFrhcNDVUgSQVDzqsqX38DjhPEPATpid04LCoUr2WXhQTEZ8ct/EgJCUDpyNw==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.0.tgz", + "integrity": "sha512-6p3nHpby0DM/v15IFKMjAaayFhqnXV52aEmv1whZHX56pdkK+MEaLoQWj+H42ssFarP1PcomVhbsR4pkz09qBg==", "cpu": [ "x64" ], @@ -918,13 +1057,13 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.11.tgz", - "integrity": "sha512-Hf+Sad9nVwvtxy4DXCZQqLpgmRTQqyFyhT3bZ4F2XlJCjxGmRFF0Shwn9rzhOYRB61w9VMXUkxlBy56dk9JJiQ==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.0.tgz", + "integrity": "sha512-BFelBGfrBwk6LVrmFzCq1u1dZbG4zy/Kp93w2+y83Q5UGYF1d8sCzeLI9NXjKyujjBBniQa8R8PzLFAUrSM9OA==", "cpu": [ "x64" ], @@ -934,13 +1073,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.11.tgz", - "integrity": "sha512-0P58Sbi0LctOMOQbpEOvOL44Ne0sqbS0XWHMvvrg6NE5jQ1xguCSSw9jQeUk2lfrXYsKDdOe6K+oZiwKPilYPQ==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.0.tgz", + "integrity": "sha512-lY6AC8p4Cnb7xYHuIxQ6iYPe6MfO2CC43XXKo9nBXDb35krYt7KGhQnOkRGar5psxYkircpCqfbNDB4uJbS2jQ==", "cpu": [ "arm64" ], @@ -950,13 +1089,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.11.tgz", - "integrity": "sha512-6YOrWS+sDJDmshdBIQU+Uoyh7pQKrdykdefC1avn76ss5c+RN6gut3LZA4E2cH5xUEp5/cA0+YxRaVtRAb0xBg==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.0.tgz", + "integrity": "sha512-7L1bHlOTcO4ByvI7OXVI5pNN6HSu6pUQq9yodga8izeuB1KcT2UkHaH6118QJwopExPn0rMHIseCTx1CRo/uNA==", "cpu": [ "ia32" ], @@ -966,13 +1105,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.11.tgz", - "integrity": "sha512-vfkhltrjCAb603XaFhqhAF4LGDi2M4OrCRrFusyQ+iTLQ/o60QQXxc9cZC/FFpihBI9N1Grn6SMKVJ4KP7Fuiw==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.0.tgz", + "integrity": "sha512-Arm+WgUFLUATuoxCJcahGuk6Yj9Pzxd6l11Zb/2aAuv5kWWvvfhLFo2fni4uSK5vzlUdCGZ/BdV5tH8klj8p8g==", "cpu": [ "x64" ], @@ -982,7 +1121,7 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -1015,13 +1154,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.1.tgz", - "integrity": "sha512-fo6Mtm5mWyKjA/Chy1BYTdn5mGJoDNjC7C64ug20ADsRDGrA85bN3uK3MaKbeRkRuuIEAR5N33Jr1pbm411/PA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", + "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.5", + "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -1030,9 +1169,9 @@ } }, "node_modules/@eslint/core": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.10.0.tgz", - "integrity": "sha512-gFHJ+xBOo4G3WRlR1e/3G8A6/KZAH6zcE/hkLRCZTi/B9avAG365QhFA8uOGzTMqgTghpn7/fSnscW++dpMSAw==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", + "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1043,9 +1182,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz", - "integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.0.tgz", + "integrity": "sha512-yaVPAiNAalnCZedKLdR21GOGILMLKPyqSLWaAjQFvYA2i/ciDi8ArYVr69Anohb6cH2Ukhqti4aFnYyPm8wdwQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1080,9 +1219,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.18.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.18.0.tgz", - "integrity": "sha512-fK6L7rxcq6/z+AaQMtiFTkvbHkBLNlwyRxHpKawP0x3u9+NC6MQTnFW+AdpwC6gfHTW0051cokQgtTN2FqlxQA==", + "version": "9.21.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.21.0.tgz", + "integrity": "sha512-BqStZ3HX8Yz6LvsF5ByXYrtigrV5AXADWLAGc7PH/1SxOb7/FIYYMszZZWiUou/GB9P2lXWk2SV4d+Z8h0nknw==", "dev": true, "license": "MIT", "engines": { @@ -1090,9 +1229,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.5.tgz", - "integrity": "sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1100,13 +1239,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.5.tgz", - "integrity": "sha512-lB05FkqEdUg2AA0xEbUz0SnkXT1LcCTa438W4IWTUh4hdOnVbQyOJ81OrDXsJk/LSiJHubgGEFoR5EHq1NsH1A==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.7.tgz", + "integrity": "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.10.0", + "@eslint/core": "^0.12.0", "levn": "^0.4.1" }, "engines": { @@ -1124,22 +1263,22 @@ } }, "node_modules/@golevelup/nestjs-discovery": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@golevelup/nestjs-discovery/-/nestjs-discovery-4.0.1.tgz", - "integrity": "sha512-HFXBJayEkYcU/bbxOztozONdWaZR34ZeJ2zRbZIWY8d5K26oPZQTvJ4L0STW3XVRGWtoE0WBpmx2YPNgYvcmJQ==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@golevelup/nestjs-discovery/-/nestjs-discovery-4.0.3.tgz", + "integrity": "sha512-8w3CsXHN7+7Sn2i419Eal1Iw/kOjAd6Kb55M/ZqKBBwACCMn4WiEuzssC71LpBMI1090CiDxuelfPRwwIrQK+A==", "license": "MIT", "dependencies": { "lodash": "^4.17.21" }, "peerDependencies": { - "@nestjs/common": "^10.x", - "@nestjs/core": "^10.x" + "@nestjs/common": "^10.x || ^11.0.0", + "@nestjs/core": "^10.x || ^11.0.0" } }, "node_modules/@grpc/grpc-js": { - "version": "1.12.4", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.12.4.tgz", - "integrity": "sha512-NBhrxEWnFh0FxeA0d//YP95lRFsSx2TNLEUQg4/W+5f/BMxcCjgOOIT24iD+ZB/tZw057j44DaIxja7w4XMrhg==", + "version": "1.12.6", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.12.6.tgz", + "integrity": "sha512-JXUj6PI0oqqzTGvKtzOkxtpsyPRNsrmhh41TtIz/zEB6J+AUiZZ0dxWzcMwO9Ns5rmSPuMdghlTbUuqIM48d3Q==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.7.13", @@ -1235,9 +1374,9 @@ } }, "node_modules/@humanwhocodes/retry": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz", - "integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", + "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1609,6 +1748,324 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@inquirer/checkbox": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.1.2.tgz", + "integrity": "sha512-PL9ixC5YsPXzXhAZFUPmkXGxfgjkdfZdPEPPmt4kFwQ4LBMDG9n/nHXYRGGZSKZJs+d1sGKWgS2GiPzVRKUdtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.7", + "@inquirer/figures": "^1.0.10", + "@inquirer/type": "^3.0.4", + "ansi-escapes": "^4.3.2", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.6.tgz", + "integrity": "sha512-6ZXYK3M1XmaVBZX6FCfChgtponnL0R6I7k8Nu+kaoNkT828FVZTcca1MqmWQipaW2oNREQl5AaPCUOOCVNdRMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.7", + "@inquirer/type": "^3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.1.7", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.7.tgz", + "integrity": "sha512-AA9CQhlrt6ZgiSy6qoAigiA1izOa751ugX6ioSjqgJ+/Gd+tEN/TORk5sUYNjXuHWfW0r1n/a6ak4u/NqHHrtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/figures": "^1.0.10", + "@inquirer/type": "^3.0.4", + "ansi-escapes": "^4.3.2", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.7.tgz", + "integrity": "sha512-gktCSQtnSZHaBytkJKMKEuswSk2cDBuXX5rxGFv306mwHfBPjg5UAldw9zWGoEyvA9KpRDkeM4jfrx0rXn0GyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.7", + "@inquirer/type": "^3.0.4", + "external-editor": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.9.tgz", + "integrity": "sha512-Xxt6nhomWTAmuSX61kVgglLjMEFGa+7+F6UUtdEUeg7fg4r9vaFttUUKrtkViYYrQBA5Ia1tkOJj2koP9BuLig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.7", + "@inquirer/type": "^3.0.4", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.10.tgz", + "integrity": "sha512-Ey6176gZmeqZuY/W/nZiUyvmb1/qInjcpiZjXWi6nON+nxJpD1bxtSoBxNliGISae32n6OwbY+TSXPZ1CfS4bw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.1.6.tgz", + "integrity": "sha512-1f5AIsZuVjPT4ecA8AwaxDFNHny/tSershP/cTvTDxLdiIGTeILNcKozB0LaYt6mojJLUbOYhpIxicaYf7UKIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.7", + "@inquirer/type": "^3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.9.tgz", + "integrity": "sha512-iN2xZvH3tyIYXLXBvlVh0npk1q/aVuKXZo5hj+K3W3D4ngAEq/DkLpofRzx6oebTUhBvOgryZ+rMV0yImKnG3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.7", + "@inquirer/type": "^3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.9.tgz", + "integrity": "sha512-xBEoOw1XKb0rIN208YU7wM7oJEHhIYkfG7LpTJAEW913GZeaoQerzf5U/LSHI45EVvjAdgNXmXgH51cUXKZcJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.7", + "@inquirer/type": "^3.0.4", + "ansi-escapes": "^4.3.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.3.2.tgz", + "integrity": "sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.1.2", + "@inquirer/confirm": "^5.1.6", + "@inquirer/editor": "^4.2.7", + "@inquirer/expand": "^4.0.9", + "@inquirer/input": "^4.1.6", + "@inquirer/number": "^3.0.9", + "@inquirer/password": "^4.0.9", + "@inquirer/rawlist": "^4.0.9", + "@inquirer/search": "^3.0.9", + "@inquirer/select": "^4.0.9" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.0.9.tgz", + "integrity": "sha512-+5t6ebehKqgoxV8fXwE49HkSF2Rc9ijNiVGEQZwvbMI61/Q5RcD+jWD6Gs1tKdz5lkI8GRBL31iO0HjGK1bv+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.7", + "@inquirer/type": "^3.0.4", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.0.9.tgz", + "integrity": "sha512-DWmKztkYo9CvldGBaRMr0ETUHgR86zE6sPDVOHsqz4ISe9o1LuiWfgJk+2r75acFclA93J/lqzhT0dTjCzHuoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.7", + "@inquirer/figures": "^1.0.10", + "@inquirer/type": "^3.0.4", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.0.9.tgz", + "integrity": "sha512-BpJyJe7Dkhv2kz7yG7bPSbJLQuu/rqyNlF1CfiiFeFwouegfH+zh13KDyt6+d9DwucKo7hqM3wKLLyJxZMO+Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.7", + "@inquirer/figures": "^1.0.10", + "@inquirer/type": "^3.0.4", + "ansi-escapes": "^4.3.2", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.4.tgz", + "integrity": "sha512-2MNFrDY8jkFYc9Il9DgLsHhMzuHnOYM1+CUYVWbzu9oT0hC7V7EcYvdCKeoll/Fcci04A+ERZ9wcc7cQ8lTkIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@ioredis/commands": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", @@ -1632,18 +2089,6 @@ "node": ">=12" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, "node_modules/@isaacs/cliui/node_modules/ansi-styles": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", @@ -1679,21 +2124,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", @@ -1790,19 +2220,6 @@ "url": "https://opencollective.com/js-sdsl" } }, - "node_modules/@ljharb/through": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@ljharb/through/-/through-2.3.13.tgz", - "integrity": "sha512-/gKJun8NNiWGZJkGzI/Ragc53cOdcLNdzjLaIa+GEjguQs0ulsurx8WN0jijdK9yPqDvziX995sMRLyLt1uZMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/@lukeed/csprng": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", @@ -1954,9 +2371,9 @@ ] }, "node_modules/@nestjs/bull-shared": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-11.0.1.tgz", - "integrity": "sha512-FqpmIjhCONaYo+5AjtggPdo2lRIM/fv1VHiEq7YwFZBTNSPW0eOvcT96JDb5q4OuvLvADxgpnwP7rmzZywMMiw==", + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-11.0.2.tgz", + "integrity": "sha512-dFlttJvBqIFD6M8JVFbkrR4Feb39OTAJPJpFVILU50NOJCM4qziRw3dSNG84Q3v+7/M6xUGMFdZRRGvBBKxoSA==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -1967,12 +2384,12 @@ } }, "node_modules/@nestjs/bullmq": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-11.0.1.tgz", - "integrity": "sha512-BntU0Zfiyk4R5hlasUV22n1HuqmWWKvsx3knSR5A9/5vce808pmHOmHrtm4GZDs/8Pw9X8UGY8zdLe4a36S6KQ==", + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-11.0.2.tgz", + "integrity": "sha512-Lq6lGpKkETsm0RDcUktlzsthFoE3A5QTMp2FwPi1eztKqKD6/90KS1TcnC9CJFzjpUaYnQzIMrlNs55e+/wsHA==", "license": "MIT", "dependencies": { - "@nestjs/bull-shared": "^11.0.1", + "@nestjs/bull-shared": "^11.0.2", "tslib": "2.8.1" }, "peerDependencies": { @@ -1982,40 +2399,40 @@ } }, "node_modules/@nestjs/cli": { - "version": "10.4.9", - "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz", - "integrity": "sha512-s8qYd97bggqeK7Op3iD49X2MpFtW4LVNLAwXFkfbRxKME6IYT7X0muNTJ2+QfI8hpbNx9isWkrLWIp+g5FOhiA==", + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.5.tgz", + "integrity": "sha512-ab/d8Ple+dMSQ4pC7RSNjhntpT8gFQQE8y/F/ilaplp7zPGpuxbayRtYbsA/wc1UkJHORDckrqFc8Jh8mrTq2A==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "17.3.11", - "@angular-devkit/schematics": "17.3.11", - "@angular-devkit/schematics-cli": "17.3.11", - "@nestjs/schematics": "^10.0.1", - "chalk": "4.1.2", - "chokidar": "3.6.0", + "@angular-devkit/core": "19.1.8", + "@angular-devkit/schematics": "19.1.8", + "@angular-devkit/schematics-cli": "19.1.8", + "@inquirer/prompts": "7.3.2", + "@nestjs/schematics": "^11.0.1", + "ansis": "3.16.0", + "chokidar": "4.0.3", "cli-table3": "0.6.5", "commander": "4.1.1", "fork-ts-checker-webpack-plugin": "9.0.2", - "glob": "10.4.5", - "inquirer": "8.2.6", + "glob": "11.0.1", "node-emoji": "1.11.0", "ora": "5.4.1", "tree-kill": "1.2.2", "tsconfig-paths": "4.2.0", "tsconfig-paths-webpack-plugin": "4.2.0", - "typescript": "5.7.2", - "webpack": "5.97.1", + "typescript": "5.7.3", + "webpack": "5.98.0", "webpack-node-externals": "3.0.0" }, "bin": { "nest": "bin/nest.js" }, "engines": { - "node": ">= 16.14" + "node": ">= 20.11" }, "peerDependencies": { - "@swc/cli": "^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0", + "@swc/cli": "^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0", "@swc/core": "^1.3.62" }, "peerDependenciesMeta": { @@ -2027,10 +2444,112 @@ } } }, + "node_modules/@nestjs/cli/node_modules/@angular-devkit/core": { + "version": "19.1.8", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.1.8.tgz", + "integrity": "sha512-j1zHKvOsGwu5YwAZGuzi835R9vcW7PkfxmSRIJeVl+vawgk31K3zFb4UPH8AY/NPWYqXIAnwpka3HC1+JrWLWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.17.1", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.2", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@nestjs/cli/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@nestjs/cli/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nestjs/cli/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@nestjs/cli/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nestjs/cli/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@nestjs/cli/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, "node_modules/@nestjs/cli/node_modules/typescript": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", - "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2042,9 +2561,9 @@ } }, "node_modules/@nestjs/common": { - "version": "10.4.15", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.15.tgz", - "integrity": "sha512-vaLg1ZgwhG29BuLDxPA9OAcIlgqzp9/N8iG0wGapyUNTf4IY4O6zAHgN6QalwLhFxq7nOI021vdRojR1oF3bqg==", + "version": "11.0.11", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.0.11.tgz", + "integrity": "sha512-b3zYiho5/XGCnLa7W2hHv5ecSBR1huQrXCHu6pxd+g2HY2B7sKP5CXHMv4gHYqpIqu4ClOb7Q4tLKXMp9LyLUg==", "license": "MIT", "dependencies": { "iterare": "1.2.1", @@ -2071,28 +2590,31 @@ } }, "node_modules/@nestjs/core": { - "version": "10.4.15", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.15.tgz", - "integrity": "sha512-UBejmdiYwaH6fTsz2QFBlC1cJHM+3UDeLZN+CiP9I1fRv2KlBZsmozGLbV5eS1JAVWJB4T5N5yQ0gjN8ZvcS2w==", + "version": "11.0.11", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.0.11.tgz", + "integrity": "sha512-jMH3jrjrPiaGrkQ5hANNcgDWN+j+hcM5GMQ3jSs4vOWNs3lmKHTVR11wJ9y5tTNnwKydzMogeju0VTUdfXDI5Q==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@nuxtjs/opencollective": "0.3.2", + "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", - "path-to-regexp": "3.3.0", + "path-to-regexp": "8.2.0", "tslib": "2.8.1", "uid": "2.0.2" }, + "engines": { + "node": ">= 20" + }, "funding": { "type": "opencollective", "url": "https://opencollective.com/nest" }, "peerDependencies": { - "@nestjs/common": "^10.0.0", - "@nestjs/microservices": "^10.0.0", - "@nestjs/platform-express": "^10.0.0", - "@nestjs/websockets": "^10.0.0", + "@nestjs/common": "^11.0.0", + "@nestjs/microservices": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/websockets": "^11.0.0", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, @@ -2109,9 +2631,9 @@ } }, "node_modules/@nestjs/event-emitter": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-3.0.0.tgz", - "integrity": "sha512-WbvzQQ9BGnj27onh2qSLND2+4iA6Pfp4K+HLlqunB0Uz0614O8lGMtcveSss2IOxsox8EhSI54WAvuAsDrX1hA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-3.0.1.tgz", + "integrity": "sha512-0Ln/x+7xkU6AJFOcQI9tIhUMXVF7D5itiaQGOyJbXtlAfAIt8gzDdJm+Im7cFzKoWkiW5nCXCPh6GSvdQd/3Dw==", "license": "MIT", "dependencies": { "eventemitter2": "6.4.9" @@ -2122,12 +2644,12 @@ } }, "node_modules/@nestjs/mapped-types": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.6.tgz", - "integrity": "sha512-84ze+CPfp1OWdpRi1/lOu59hOhTz38eVzJvRKrg9ykRFwDz+XleKfMsG0gUqNZYFa6v53XYzeD+xItt8uDW7NQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.0.tgz", + "integrity": "sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==", "license": "MIT", "peerDependencies": { - "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/common": "^10.0.0 || ^11.0.0", "class-transformer": "^0.4.0 || ^0.5.0", "class-validator": "^0.13.0 || ^0.14.0", "reflect-metadata": "^0.1.12 || ^0.2.0" @@ -2142,15 +2664,15 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "10.4.15", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.15.tgz", - "integrity": "sha512-63ZZPkXHjoDyO7ahGOVcybZCRa7/Scp6mObQKjcX/fTEq1YJeU75ELvMsuQgc8U2opMGOBD7GVuc4DV0oeDHoA==", + "version": "11.0.11", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.0.11.tgz", + "integrity": "sha512-iv6nH66i/RuRQufg5UUboQ4jQX4NuuePrYQpHB3ueiEIhJm2yLhhNYM6Y2l/76y9woW2eckbiqbzmW/JajAgeQ==", "license": "MIT", "dependencies": { - "body-parser": "1.20.3", "cors": "2.8.5", - "express": "4.21.2", - "multer": "1.4.4-lts.1", + "express": "5.0.1", + "multer": "1.4.5-lts.1", + "path-to-regexp": "8.2.0", "tslib": "2.8.1" }, "funding": { @@ -2158,14 +2680,14 @@ "url": "https://opencollective.com/nest" }, "peerDependencies": { - "@nestjs/common": "^10.0.0", - "@nestjs/core": "^10.0.0" + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0" } }, "node_modules/@nestjs/platform-socket.io": { - "version": "10.4.15", - "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.15.tgz", - "integrity": "sha512-KZAxNEADPwoORixh3NJgGYWMVGORVPKeTqjD7hbF8TPDLKWWxru9yasBQwEz2/wXH/WgpkQbbaYwx4nUjCIVpw==", + "version": "11.0.11", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.0.11.tgz", + "integrity": "sha512-+bLrtTSPDX6AxrL9PbR5lEgcEnn6oFzkGpLUcm3Xs9x5OBejzJh1tiWgBGJRQIh3l9iIG8/mQ8hNwufAt8SIcA==", "license": "MIT", "dependencies": { "socket.io": "4.8.1", @@ -2176,15 +2698,15 @@ "url": "https://opencollective.com/nest" }, "peerDependencies": { - "@nestjs/common": "^10.0.0", - "@nestjs/websockets": "^10.0.0", + "@nestjs/common": "^11.0.0", + "@nestjs/websockets": "^11.0.0", "rxjs": "^7.1.0" } }, "node_modules/@nestjs/schedule": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-5.0.0.tgz", - "integrity": "sha512-RHqJIOo3AQvdeq0WuIFDqa5N0CkgxgqwmWRla96S6GmFV6qkQD1//EeH4k19MeCu4Ac9PzZ2y/Hu0zK9f//BQg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-5.0.1.tgz", + "integrity": "sha512-kFoel84I4RyS2LNPH6yHYTKxB16tb3auAEciFuc788C3ph6nABkUfzX5IE+unjVaRX+3GuruJwurNepMlHXpQg==", "license": "MIT", "dependencies": { "cron": "3.5.0" @@ -2195,14 +2717,14 @@ } }, "node_modules/@nestjs/schematics": { - "version": "10.2.3", - "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz", - "integrity": "sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg==", + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.2.tgz", + "integrity": "sha512-C4KM3BHBG6tRX8t5UrHdUq8Y49asEfJUora/fBXge3UTAnxKGlXc20p5s2Q0Q1+l+1YaXqTrKGSIbYXdPX8r9g==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "17.3.11", - "@angular-devkit/schematics": "17.3.11", + "@angular-devkit/core": "19.2.0", + "@angular-devkit/schematics": "19.2.0", "comment-json": "4.2.5", "jsonc-parser": "3.3.1", "pluralize": "8.0.0" @@ -2211,30 +2733,148 @@ "typescript": ">=4.8.2" } }, - "node_modules/@nestjs/schematics/node_modules/jsonc-parser": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", - "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "node_modules/@nestjs/schematics/node_modules/@angular-devkit/core": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.0.tgz", + "integrity": "sha512-qd2nYoHZOYWRsu4MjXG8KiDtfM9ZDRR2rDGa+rDZ3CYAsngCrPmqOebun10dncUjwAidX49P4S2U2elOmX3VYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.17.1", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.2", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@nestjs/schematics/node_modules/@angular-devkit/schematics": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.0.tgz", + "integrity": "sha512-cGGqUGqBXIGJkeL65l70y0BflDAu/0Zi/ohbYat3hvadFfumRJnVElVfJ59JtWO7FfKQjxcwCVTyuQ/tevX/9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.0", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.17", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@nestjs/schematics/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@nestjs/schematics/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nestjs/schematics/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, "license": "MIT" }, + "node_modules/@nestjs/schematics/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nestjs/schematics/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@nestjs/schematics/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, "node_modules/@nestjs/swagger": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-8.1.1.tgz", - "integrity": "sha512-5Mda7H1DKnhKtlsb0C7PYshcvILv8UFyUotHzxmWh0G65Z21R3LZH/J8wmpnlzL4bmXIfr42YwbEwRxgzpJ5sQ==", + "version": "11.0.6", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.0.6.tgz", + "integrity": "sha512-W/0aQWiEfEcXKd/dYO0DbVpYhlKNVMAhO4haahUyrYe20eXaaDY0T5exA2U8IsCcXZePWZuodRUiiXo8jcMYbA==", "license": "MIT", "dependencies": { - "@microsoft/tsdoc": "^0.15.0", - "@nestjs/mapped-types": "2.0.6", + "@microsoft/tsdoc": "0.15.1", + "@nestjs/mapped-types": "2.1.0", "js-yaml": "4.1.0", "lodash": "4.17.21", - "path-to-regexp": "3.3.0", - "swagger-ui-dist": "5.18.2" + "path-to-regexp": "8.2.0", + "swagger-ui-dist": "5.19.0" }, "peerDependencies": { - "@fastify/static": "^6.0.0 || ^7.0.0", - "@nestjs/common": "^9.0.0 || ^10.0.0", - "@nestjs/core": "^9.0.0 || ^10.0.0", + "@fastify/static": "^8.0.0", + "@nestjs/common": "^11.0.1", + "@nestjs/core": "^11.0.1", "class-transformer": "*", "class-validator": "*", "reflect-metadata": "^0.1.12 || ^0.2.0" @@ -2252,9 +2892,9 @@ } }, "node_modules/@nestjs/testing": { - "version": "10.4.15", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.15.tgz", - "integrity": "sha512-eGlWESkACMKti+iZk1hs6FUY/UqObmMaa8HAN9JLnaYkoLf1Jeh+EuHlGnfqo/Rq77oznNLIyaA3PFjrFDlNUg==", + "version": "11.0.11", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.0.11.tgz", + "integrity": "sha512-SoMIrhRpElV53btmGnEwpIQmXn2Xcztb9ae3lv+eVVnPHQuyB2zlgDIQVNjicbj7+3jdycX52KctOoj2eXEo1Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2265,10 +2905,10 @@ "url": "https://opencollective.com/nest" }, "peerDependencies": { - "@nestjs/common": "^10.0.0", - "@nestjs/core": "^10.0.0", - "@nestjs/microservices": "^10.0.0", - "@nestjs/platform-express": "^10.0.0" + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/microservices": "^11.0.0", + "@nestjs/platform-express": "^11.0.0" }, "peerDependenciesMeta": { "@nestjs/microservices": { @@ -2279,39 +2919,10 @@ } } }, - "node_modules/@nestjs/typeorm": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz", - "integrity": "sha512-H738bJyydK4SQkRCTeh1aFBxoO1E9xdL/HaLGThwrqN95os5mEyAtK7BLADOS+vldP4jDZ2VQPLj4epWwRqCeQ==", - "license": "MIT", - "dependencies": { - "uuid": "9.0.1" - }, - "peerDependencies": { - "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", - "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", - "reflect-metadata": "^0.1.13 || ^0.2.0", - "rxjs": "^7.2.0", - "typeorm": "^0.3.0" - } - }, - "node_modules/@nestjs/typeorm/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@nestjs/websockets": { - "version": "10.4.15", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.15.tgz", - "integrity": "sha512-OmCUJwvtagzXfMVko595O98UI3M9zg+URL+/HV7vd3QPMCZ3uGCKSq15YYJ99LHJn9NyK4e4Szm2KnHtUg2QzA==", + "version": "11.0.11", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.0.11.tgz", + "integrity": "sha512-9sNNT/kYA534iaFyZ9MrOXKwQFuJArsMXhT6ywVxaWKQ84lVbV/sDmdmJUe9mzUGLPiHMn+m3oDUO9MiLTEKPA==", "license": "MIT", "dependencies": { "iterare": "1.2.1", @@ -2319,9 +2930,9 @@ "tslib": "2.8.1" }, "peerDependencies": { - "@nestjs/common": "^10.0.0", - "@nestjs/core": "^10.0.0", - "@nestjs/platform-socket.io": "^10.0.0", + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/platform-socket.io": "^11.0.0", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, @@ -2500,22 +3111,20 @@ "node": ">= 8" } }, - "node_modules/@nuxtjs/opencollective": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@nuxtjs/opencollective/-/opencollective-0.3.2.tgz", - "integrity": "sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA==", + "node_modules/@nuxt/opencollective": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@nuxt/opencollective/-/opencollective-0.4.1.tgz", + "integrity": "sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==", "license": "MIT", "dependencies": { - "chalk": "^4.1.0", - "consola": "^2.15.0", - "node-fetch": "^2.6.1" + "consola": "^3.2.3" }, "bin": { "opencollective": "bin/opencollective.js" }, "engines": { - "node": ">=8.0.0", - "npm": ">=5.0.0" + "node": "^14.18.0 || >=16.10.0", + "npm": ">=5.10.0" } }, "node_modules/@opentelemetry/api": { @@ -2528,9 +3137,9 @@ } }, "node_modules/@opentelemetry/api-logs": { - "version": "0.57.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.1.tgz", - "integrity": "sha512-I4PHczeujhQAQv6ZBzqHYEUiggZL4IdSMixtVD3EYqbdrjujE7kRfI5QohjlPoJm8BvenoW5YaTMWRrbpot6tg==", + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", + "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "^1.3.0" @@ -2540,58 +3149,58 @@ } }, "node_modules/@opentelemetry/auto-instrumentations-node": { - "version": "0.55.3", - "resolved": "https://registry.npmjs.org/@opentelemetry/auto-instrumentations-node/-/auto-instrumentations-node-0.55.3.tgz", - "integrity": "sha512-tX5k3ZG8Nk6f1DHAF0K1ClP/OiW2hNuSeCVqDHNMcJ58dZSiad0XO2mwrvSipo77/DPXXUl0j9MxqmUVITdujQ==", + "version": "0.56.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/auto-instrumentations-node/-/auto-instrumentations-node-0.56.1.tgz", + "integrity": "sha512-4cK0+unfkXRRbQQg2r9K3ki8JlE0j9Iw8+4DZEkChShAnmviiE+/JMgHGvK+VVcLrSlgV6BBHv4+ZTLukQwhkA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0", - "@opentelemetry/instrumentation-amqplib": "^0.46.0", - "@opentelemetry/instrumentation-aws-lambda": "^0.50.2", - "@opentelemetry/instrumentation-aws-sdk": "^0.49.0", - "@opentelemetry/instrumentation-bunyan": "^0.45.0", - "@opentelemetry/instrumentation-cassandra-driver": "^0.45.0", - "@opentelemetry/instrumentation-connect": "^0.43.0", - "@opentelemetry/instrumentation-cucumber": "^0.13.0", - "@opentelemetry/instrumentation-dataloader": "^0.16.0", - "@opentelemetry/instrumentation-dns": "^0.43.0", - "@opentelemetry/instrumentation-express": "^0.47.0", - "@opentelemetry/instrumentation-fastify": "^0.44.1", - "@opentelemetry/instrumentation-fs": "^0.19.0", - "@opentelemetry/instrumentation-generic-pool": "^0.43.0", - "@opentelemetry/instrumentation-graphql": "^0.47.0", - "@opentelemetry/instrumentation-grpc": "^0.57.0", - "@opentelemetry/instrumentation-hapi": "^0.45.1", - "@opentelemetry/instrumentation-http": "^0.57.0", - "@opentelemetry/instrumentation-ioredis": "^0.47.0", - "@opentelemetry/instrumentation-kafkajs": "^0.7.0", - "@opentelemetry/instrumentation-knex": "^0.44.0", - "@opentelemetry/instrumentation-koa": "^0.47.0", - "@opentelemetry/instrumentation-lru-memoizer": "^0.44.0", - "@opentelemetry/instrumentation-memcached": "^0.43.0", - "@opentelemetry/instrumentation-mongodb": "^0.51.0", - "@opentelemetry/instrumentation-mongoose": "^0.46.0", - "@opentelemetry/instrumentation-mysql": "^0.45.0", - "@opentelemetry/instrumentation-mysql2": "^0.45.0", - "@opentelemetry/instrumentation-nestjs-core": "^0.44.0", - "@opentelemetry/instrumentation-net": "^0.43.0", - "@opentelemetry/instrumentation-pg": "^0.50.0", - "@opentelemetry/instrumentation-pino": "^0.46.0", - "@opentelemetry/instrumentation-redis": "^0.46.0", - "@opentelemetry/instrumentation-redis-4": "^0.46.0", - "@opentelemetry/instrumentation-restify": "^0.45.0", - "@opentelemetry/instrumentation-router": "^0.44.0", - "@opentelemetry/instrumentation-socket.io": "^0.46.0", - "@opentelemetry/instrumentation-tedious": "^0.18.0", - "@opentelemetry/instrumentation-undici": "^0.10.0", - "@opentelemetry/instrumentation-winston": "^0.44.0", - "@opentelemetry/resource-detector-alibaba-cloud": "^0.30.0", - "@opentelemetry/resource-detector-aws": "^1.11.0", - "@opentelemetry/resource-detector-azure": "^0.6.0", - "@opentelemetry/resource-detector-container": "^0.6.0", - "@opentelemetry/resource-detector-gcp": "^0.33.0", + "@opentelemetry/instrumentation": "^0.57.1", + "@opentelemetry/instrumentation-amqplib": "^0.46.1", + "@opentelemetry/instrumentation-aws-lambda": "^0.50.3", + "@opentelemetry/instrumentation-aws-sdk": "^0.49.1", + "@opentelemetry/instrumentation-bunyan": "^0.45.1", + "@opentelemetry/instrumentation-cassandra-driver": "^0.45.1", + "@opentelemetry/instrumentation-connect": "^0.43.1", + "@opentelemetry/instrumentation-cucumber": "^0.14.1", + "@opentelemetry/instrumentation-dataloader": "^0.16.1", + "@opentelemetry/instrumentation-dns": "^0.43.1", + "@opentelemetry/instrumentation-express": "^0.47.1", + "@opentelemetry/instrumentation-fastify": "^0.44.2", + "@opentelemetry/instrumentation-fs": "^0.19.1", + "@opentelemetry/instrumentation-generic-pool": "^0.43.1", + "@opentelemetry/instrumentation-graphql": "^0.47.1", + "@opentelemetry/instrumentation-grpc": "^0.57.1", + "@opentelemetry/instrumentation-hapi": "^0.45.2", + "@opentelemetry/instrumentation-http": "^0.57.1", + "@opentelemetry/instrumentation-ioredis": "^0.47.1", + "@opentelemetry/instrumentation-kafkajs": "^0.7.1", + "@opentelemetry/instrumentation-knex": "^0.44.1", + "@opentelemetry/instrumentation-koa": "^0.47.1", + "@opentelemetry/instrumentation-lru-memoizer": "^0.44.1", + "@opentelemetry/instrumentation-memcached": "^0.43.1", + "@opentelemetry/instrumentation-mongodb": "^0.52.0", + "@opentelemetry/instrumentation-mongoose": "^0.46.1", + "@opentelemetry/instrumentation-mysql": "^0.45.1", + "@opentelemetry/instrumentation-mysql2": "^0.45.2", + "@opentelemetry/instrumentation-nestjs-core": "^0.44.1", + "@opentelemetry/instrumentation-net": "^0.43.1", + "@opentelemetry/instrumentation-pg": "^0.51.1", + "@opentelemetry/instrumentation-pino": "^0.46.1", + "@opentelemetry/instrumentation-redis": "^0.46.1", + "@opentelemetry/instrumentation-redis-4": "^0.46.1", + "@opentelemetry/instrumentation-restify": "^0.45.1", + "@opentelemetry/instrumentation-router": "^0.44.1", + "@opentelemetry/instrumentation-socket.io": "^0.46.1", + "@opentelemetry/instrumentation-tedious": "^0.18.1", + "@opentelemetry/instrumentation-undici": "^0.10.1", + "@opentelemetry/instrumentation-winston": "^0.44.1", + "@opentelemetry/resource-detector-alibaba-cloud": "^0.30.1", + "@opentelemetry/resource-detector-aws": "^1.12.0", + "@opentelemetry/resource-detector-azure": "^0.6.1", + "@opentelemetry/resource-detector-container": "^0.6.1", + "@opentelemetry/resource-detector-gcp": "^0.33.1", "@opentelemetry/resources": "^1.24.0", - "@opentelemetry/sdk-node": "^0.57.0" + "@opentelemetry/sdk-node": "^0.57.1" }, "engines": { "node": ">=14" @@ -2628,16 +3237,17 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-grpc": { - "version": "0.57.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.57.1.tgz", - "integrity": "sha512-RL8qmZH1H/H7Hbj0xKxF0Gg8kX9ic0aoMS3Kv5kj864lWxlpuR5YtGGn5OjGYwCmq6nYbsNy257fFp1U63pABw==", + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.57.2.tgz", + "integrity": "sha512-eovEy10n3umjKJl2Ey6TLzikPE+W4cUQ4gCwgGP1RqzTGtgDra0WjIqdy29ohiUKfvmbiL3MndZww58xfIvyFw==", "license": "Apache-2.0", "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "1.30.1", - "@opentelemetry/otlp-grpc-exporter-base": "0.57.1", - "@opentelemetry/otlp-transformer": "0.57.1", - "@opentelemetry/sdk-logs": "0.57.1" + "@opentelemetry/otlp-exporter-base": "0.57.2", + "@opentelemetry/otlp-grpc-exporter-base": "0.57.2", + "@opentelemetry/otlp-transformer": "0.57.2", + "@opentelemetry/sdk-logs": "0.57.2" }, "engines": { "node": ">=14" @@ -2647,16 +3257,16 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-http": { - "version": "0.57.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.57.1.tgz", - "integrity": "sha512-u8Cr6yDX57/n89aSJwAQNHQIYodcl6o8jTcaPKNktMvNfd7ny3R7aE7GKBC5Wg0zejP9heBgyN0OGwrPhptx7A==", + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.57.2.tgz", + "integrity": "sha512-0rygmvLcehBRp56NQVLSleJ5ITTduq/QfU7obOkyWgPpFHulwpw2LYTqNIz5TczKZuy5YY+5D3SDnXZL1tXImg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.57.1", + "@opentelemetry/api-logs": "0.57.2", "@opentelemetry/core": "1.30.1", - "@opentelemetry/otlp-exporter-base": "0.57.1", - "@opentelemetry/otlp-transformer": "0.57.1", - "@opentelemetry/sdk-logs": "0.57.1" + "@opentelemetry/otlp-exporter-base": "0.57.2", + "@opentelemetry/otlp-transformer": "0.57.2", + "@opentelemetry/sdk-logs": "0.57.2" }, "engines": { "node": ">=14" @@ -2666,17 +3276,17 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-proto": { - "version": "0.57.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.57.1.tgz", - "integrity": "sha512-WtR85NHdIVrIFfsK5bwx7miGG5WzOsuT4BNmuZ3EfZ0veowkrgoUSynsNnXW1YFXL6QhPbScjUfeTjnnV9bnIQ==", + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.57.2.tgz", + "integrity": "sha512-ta0ithCin0F8lu9eOf4lEz9YAScecezCHkMMyDkvd9S7AnZNX5ikUmC5EQOQADU+oCcgo/qkQIaKcZvQ0TYKDw==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.57.1", + "@opentelemetry/api-logs": "0.57.2", "@opentelemetry/core": "1.30.1", - "@opentelemetry/otlp-exporter-base": "0.57.1", - "@opentelemetry/otlp-transformer": "0.57.1", + "@opentelemetry/otlp-exporter-base": "0.57.2", + "@opentelemetry/otlp-transformer": "0.57.2", "@opentelemetry/resources": "1.30.1", - "@opentelemetry/sdk-logs": "0.57.1", + "@opentelemetry/sdk-logs": "0.57.2", "@opentelemetry/sdk-trace-base": "1.30.1" }, "engines": { @@ -2687,17 +3297,17 @@ } }, "node_modules/@opentelemetry/exporter-metrics-otlp-grpc": { - "version": "0.57.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.57.1.tgz", - "integrity": "sha512-8B7k5q4AUldbfvubcHApg1XQaio/cO/VUWsM5PSaRP2fsjGNwbn2ih04J3gLD+AmgslvyuDcA2SZiDXEKwAxtQ==", + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.57.2.tgz", + "integrity": "sha512-r70B8yKR41F0EC443b5CGB4rUaOMm99I5N75QQt6sHKxYDzSEc6gm48Diz1CI1biwa5tDPznpylTrywO/pT7qw==", "license": "Apache-2.0", "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "1.30.1", - "@opentelemetry/exporter-metrics-otlp-http": "0.57.1", - "@opentelemetry/otlp-exporter-base": "0.57.1", - "@opentelemetry/otlp-grpc-exporter-base": "0.57.1", - "@opentelemetry/otlp-transformer": "0.57.1", + "@opentelemetry/exporter-metrics-otlp-http": "0.57.2", + "@opentelemetry/otlp-exporter-base": "0.57.2", + "@opentelemetry/otlp-grpc-exporter-base": "0.57.2", + "@opentelemetry/otlp-transformer": "0.57.2", "@opentelemetry/resources": "1.30.1", "@opentelemetry/sdk-metrics": "1.30.1" }, @@ -2709,14 +3319,14 @@ } }, "node_modules/@opentelemetry/exporter-metrics-otlp-http": { - "version": "0.57.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.57.1.tgz", - "integrity": "sha512-jpKYVZY7fdwTdy+eAy/Mp9DZMaQpj7caMzlo3QqQDSJx5FZEY6zWzgcKvDvF6h+gdHE7LgUjaPOvJVUs354jJg==", + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.57.2.tgz", + "integrity": "sha512-ttb9+4iKw04IMubjm3t0EZsYRNWr3kg44uUuzfo9CaccYlOh8cDooe4QObDUkvx9d5qQUrbEckhrWKfJnKhemA==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "1.30.1", - "@opentelemetry/otlp-exporter-base": "0.57.1", - "@opentelemetry/otlp-transformer": "0.57.1", + "@opentelemetry/otlp-exporter-base": "0.57.2", + "@opentelemetry/otlp-transformer": "0.57.2", "@opentelemetry/resources": "1.30.1", "@opentelemetry/sdk-metrics": "1.30.1" }, @@ -2728,15 +3338,15 @@ } }, "node_modules/@opentelemetry/exporter-metrics-otlp-proto": { - "version": "0.57.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-proto/-/exporter-metrics-otlp-proto-0.57.1.tgz", - "integrity": "sha512-53AJmYJr8lypU6kAQT1/FVKR2QKcxRp4Gd54L3oF9hc2fw/FtvVfXV+PelB+qL318PqUlVjVtDOa4SQ5tAREfA==", + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-proto/-/exporter-metrics-otlp-proto-0.57.2.tgz", + "integrity": "sha512-HX068Q2eNs38uf7RIkNN9Hl4Ynl+3lP0++KELkXMCpsCbFO03+0XNNZ1SkwxPlP9jrhQahsMPMkzNXpq3fKsnw==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "1.30.1", - "@opentelemetry/exporter-metrics-otlp-http": "0.57.1", - "@opentelemetry/otlp-exporter-base": "0.57.1", - "@opentelemetry/otlp-transformer": "0.57.1", + "@opentelemetry/exporter-metrics-otlp-http": "0.57.2", + "@opentelemetry/otlp-exporter-base": "0.57.2", + "@opentelemetry/otlp-transformer": "0.57.2", "@opentelemetry/resources": "1.30.1", "@opentelemetry/sdk-metrics": "1.30.1" }, @@ -2748,9 +3358,9 @@ } }, "node_modules/@opentelemetry/exporter-prometheus": { - "version": "0.57.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-prometheus/-/exporter-prometheus-0.57.1.tgz", - "integrity": "sha512-lwwOQzyvhzioGCYmIh7mXo+RLSoEVhuO0dFzWeEiQhFkjSUOPgKQKNTgYtl2KO1L7XIbHp5LIgn4nZrYx191Rg==", + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-prometheus/-/exporter-prometheus-0.57.2.tgz", + "integrity": "sha512-VqIqXnuxWMWE/1NatAGtB1PvsQipwxDcdG4RwA/umdBcW3/iOHp0uejvFHTRN2O78ZPged87ErJajyUBPUhlDQ==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "1.30.1", @@ -2765,15 +3375,16 @@ } }, "node_modules/@opentelemetry/exporter-trace-otlp-grpc": { - "version": "0.57.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.57.1.tgz", - "integrity": "sha512-a9/4w2nyfehxMA64VGcZ4OXePGLjTz9H/dvqbOzVmIBZe9R6bkOeT68M9WoxAEdUZcJDK8XS3EloJId1rjPrag==", + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.57.2.tgz", + "integrity": "sha512-gHU1vA3JnHbNxEXg5iysqCWxN9j83d7/epTYBZflqQnTyCC4N7yZXn/dMM+bEmyhQPGjhCkNZLx4vZuChH1PYw==", "license": "Apache-2.0", "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "1.30.1", - "@opentelemetry/otlp-grpc-exporter-base": "0.57.1", - "@opentelemetry/otlp-transformer": "0.57.1", + "@opentelemetry/otlp-exporter-base": "0.57.2", + "@opentelemetry/otlp-grpc-exporter-base": "0.57.2", + "@opentelemetry/otlp-transformer": "0.57.2", "@opentelemetry/resources": "1.30.1", "@opentelemetry/sdk-trace-base": "1.30.1" }, @@ -2785,14 +3396,14 @@ } }, "node_modules/@opentelemetry/exporter-trace-otlp-http": { - "version": "0.57.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.57.1.tgz", - "integrity": "sha512-43dLEjlf6JGxpVt9RaRlJAvjHG1wGsbAuNd67RIDy/95zfKk2aNovtiGUgFdS/kcvgvS90upIUbgn0xUd9JjMg==", + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.57.2.tgz", + "integrity": "sha512-sB/gkSYFu+0w2dVQ0PWY9fAMl172PKMZ/JrHkkW8dmjCL0CYkmXeE+ssqIL/yBUTPOvpLIpenX5T9RwXRBW/3g==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "1.30.1", - "@opentelemetry/otlp-exporter-base": "0.57.1", - "@opentelemetry/otlp-transformer": "0.57.1", + "@opentelemetry/otlp-exporter-base": "0.57.2", + "@opentelemetry/otlp-transformer": "0.57.2", "@opentelemetry/resources": "1.30.1", "@opentelemetry/sdk-trace-base": "1.30.1" }, @@ -2804,14 +3415,14 @@ } }, "node_modules/@opentelemetry/exporter-trace-otlp-proto": { - "version": "0.57.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.57.1.tgz", - "integrity": "sha512-REN6UZTNoP3Tb7vuCEy+yAjNmJGi7MLqCMdDoUSbsWGwpopxtSnsbkfVfLPsZAsumWkcq0p8p6lYvqUBDhUqIA==", + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.57.2.tgz", + "integrity": "sha512-awDdNRMIwDvUtoRYxRhja5QYH6+McBLtoz1q9BeEsskhZcrGmH/V1fWpGx8n+Rc+542e8pJA6y+aullbIzQmlw==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "1.30.1", - "@opentelemetry/otlp-exporter-base": "0.57.1", - "@opentelemetry/otlp-transformer": "0.57.1", + "@opentelemetry/otlp-exporter-base": "0.57.2", + "@opentelemetry/otlp-transformer": "0.57.2", "@opentelemetry/resources": "1.30.1", "@opentelemetry/sdk-trace-base": "1.30.1" }, @@ -2841,13 +3452,12 @@ } }, "node_modules/@opentelemetry/host-metrics": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@opentelemetry/host-metrics/-/host-metrics-0.35.4.tgz", - "integrity": "sha512-3nPElbfYZ2oKNoMw2CkXkHxQryebqACcSgMbbKcn+GnGKp+h7MeOHyg21NmmTt9xgCvRHYiHNkWGkB4laP0oUw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@opentelemetry/host-metrics/-/host-metrics-0.35.5.tgz", + "integrity": "sha512-Zf9Cjl7H6JalspnK5KD1+LLKSVecSinouVctNmUxRy+WP+20KwHq+qg4hADllkEmJ99MZByLLmEmzrr7s92V6g==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/sdk-metrics": "^1.8.0", - "systeminformation": "5.22.9" + "systeminformation": "5.23.8" }, "engines": { "node": ">=14" @@ -2857,12 +3467,12 @@ } }, "node_modules/@opentelemetry/instrumentation": { - "version": "0.57.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.1.tgz", - "integrity": "sha512-SgHEKXoVxOjc20ZYusPG3Fh+RLIZTSa4x8QtD3NfgAUDyqdFFS9W1F2ZVbZkqDCdyMcQG02Ok4duUGLHJXHgbA==", + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", + "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.57.1", + "@opentelemetry/api-logs": "0.57.2", "@types/shimmer": "^1.2.0", "import-in-the-middle": "^1.8.1", "require-in-the-middle": "^7.1.1", @@ -2877,13 +3487,13 @@ } }, "node_modules/@opentelemetry/instrumentation-amqplib": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.46.0.tgz", - "integrity": "sha512-04VHHV1KIN/c1wLWwzmLI02d/welgscBJ4BuDqrHaxd+ZIdlVXK9UYQsYf3JwSeF52z/4YoSzr8bfdVBSWoMAg==", + "version": "0.46.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.46.1.tgz", + "integrity": "sha512-AyXVnlCf/xV3K/rNumzKxZqsULyITJH6OVLiW6730JPRqWA7Zc9bvYoVNpN6iOpTU8CasH34SU/ksVJmObFibQ==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { @@ -2894,14 +3504,14 @@ } }, "node_modules/@opentelemetry/instrumentation-aws-lambda": { - "version": "0.50.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-lambda/-/instrumentation-aws-lambda-0.50.2.tgz", - "integrity": "sha512-jz1a7t2q0SsiztEMyZjFLEFC4pOQ+1C588gWzl878k9Qr6TI1Wu3sa7/dikxJmeRIETcOTUilaa2Otxh6HUVlA==", + "version": "0.50.3", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-lambda/-/instrumentation-aws-lambda-0.50.3.tgz", + "integrity": "sha512-kotm/mRvSWUauudxcylc5YCDei+G/r+jnOH6q5S99aPLQ/Ms8D2yonMIxEJUILIPlthEmwLYxkw3ualWzMjm/A==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/semantic-conventions": "^1.27.0", - "@types/aws-lambda": "8.10.143" + "@types/aws-lambda": "8.10.147" }, "engines": { "node": ">=14" @@ -2911,14 +3521,14 @@ } }, "node_modules/@opentelemetry/instrumentation-aws-sdk": { - "version": "0.49.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.49.0.tgz", - "integrity": "sha512-m3yC3ni4Yo8tggbZgygS/ccAP9e/EYqsMwzooHiIymbnyZwDAB7kMZ3OrjcLVPCFx9gjNMDKW4MdwOPC0vTEeQ==", + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.49.1.tgz", + "integrity": "sha512-Vbj4BYeV/1K4Pbbfk+gQ8gwYL0w+tBeUwG88cOxnF7CLPO1XnskGV8Q3Gzut2Ah/6Dg17dBtlzEqL3UiFP2Z6A==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.57.0", - "@opentelemetry/propagation-utils": "^0.30.15", + "@opentelemetry/instrumentation": "^0.57.1", + "@opentelemetry/propagation-utils": "^0.30.16", "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { @@ -2929,14 +3539,14 @@ } }, "node_modules/@opentelemetry/instrumentation-bunyan": { - "version": "0.45.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-bunyan/-/instrumentation-bunyan-0.45.0.tgz", - "integrity": "sha512-K3ZleoOxKUzGjt0TfAT1jfSNcgyt7+toqjhWymPf2tsGUETXxaxGDzAoNepWcfIkgPauJLPpRLLKcP6LjYLILw==", + "version": "0.45.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-bunyan/-/instrumentation-bunyan-0.45.1.tgz", + "integrity": "sha512-T9POV9ccS41UjpsjLrJ4i0m8LfplBiN3dMeH9XZ2btiDrjoaWtDrst6tNb1avetBjkeshOuBp1EWKP22EVSr0g==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "^0.57.0", - "@opentelemetry/instrumentation": "^0.57.0", - "@types/bunyan": "1.8.9" + "@opentelemetry/api-logs": "^0.57.1", + "@opentelemetry/instrumentation": "^0.57.1", + "@types/bunyan": "1.8.11" }, "engines": { "node": ">=14" @@ -2946,12 +3556,12 @@ } }, "node_modules/@opentelemetry/instrumentation-cassandra-driver": { - "version": "0.45.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cassandra-driver/-/instrumentation-cassandra-driver-0.45.0.tgz", - "integrity": "sha512-IKoA0lLfF7EyIL85MfqzvfAa/Oz9zHNFXwzSiQ6Iqej89BMyOm3eYaAsyUDAvgiLG12M189temMMyRuR07YsZg==", + "version": "0.45.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cassandra-driver/-/instrumentation-cassandra-driver-0.45.1.tgz", + "integrity": "sha512-RqnP0rK2hcKK1AKcmYvedLiL6G5TvFGiSUt2vI9wN0cCBdTt9Y9+wxxY19KoGxq7e9T/aHow6P5SUhCVI1sHvQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { @@ -2962,15 +3572,15 @@ } }, "node_modules/@opentelemetry/instrumentation-connect": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.43.0.tgz", - "integrity": "sha512-Q57JGpH6T4dkYHo9tKXONgLtxzsh1ZEW5M9A/OwKrZFyEpLqWgjhcZ3hIuVvDlhb426iDF1f9FPToV/mi5rpeA==", + "version": "0.43.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.43.1.tgz", + "integrity": "sha512-ht7YGWQuV5BopMcw5Q2hXn3I8eG8TH0J/kc/GMcW4CuNTgiP6wCu44BOnucJWL3CmFWaRHI//vWyAhaC8BwePw==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/semantic-conventions": "^1.27.0", - "@types/connect": "3.4.36" + "@types/connect": "3.4.38" }, "engines": { "node": ">=14" @@ -2980,12 +3590,12 @@ } }, "node_modules/@opentelemetry/instrumentation-cucumber": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cucumber/-/instrumentation-cucumber-0.13.0.tgz", - "integrity": "sha512-ZBswBKONU2g7mhjEKF4vkTXxezq16QdvGaD5W4o01/t5KzvCZGQ6hYPsB34miJIj/hh6UrFLRDAjqb7nur5I3Q==", + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cucumber/-/instrumentation-cucumber-0.14.1.tgz", + "integrity": "sha512-ybO+tmH85pDO0ywTskmrMtZcccKyQr7Eb7wHy1keR2HFfx46SzZbjHo1AuGAX//Hook3gjM7+w211gJ2bwKe1Q==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { @@ -2996,12 +3606,12 @@ } }, "node_modules/@opentelemetry/instrumentation-dataloader": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.16.0.tgz", - "integrity": "sha512-88+qCHZC02up8PwKHk0UQKLLqGGURzS3hFQBZC7PnGwReuoKjHXS1o29H58S+QkXJpkTr2GACbx8j6mUoGjNPA==", + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.16.1.tgz", + "integrity": "sha512-K/qU4CjnzOpNkkKO4DfCLSQshejRNAJtd4esgigo/50nxCB6XCyi1dhAblUHM9jG5dRm8eu0FB+t87nIo99LYQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0" + "@opentelemetry/instrumentation": "^0.57.1" }, "engines": { "node": ">=14" @@ -3011,12 +3621,12 @@ } }, "node_modules/@opentelemetry/instrumentation-dns": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dns/-/instrumentation-dns-0.43.0.tgz", - "integrity": "sha512-bGXTyBpjSYt6B7LEj0zMfWkoveGpYf5pVEgTZmDacsG49RdfdCH5PYt3C8MEMwYEFtu2dGdKdKa2LHfefIIDdg==", + "version": "0.43.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dns/-/instrumentation-dns-0.43.1.tgz", + "integrity": "sha512-e/tMZYU1nc+k+J3259CQtqVZIPsPRSLNoAQbGEmSKrjLEY/KJSbpBZ17lu4dFVBzqoF1cZYIZxn9WPQxy4V9ng==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0" + "@opentelemetry/instrumentation": "^0.57.1" }, "engines": { "node": ">=14" @@ -3026,13 +3636,13 @@ } }, "node_modules/@opentelemetry/instrumentation-express": { - "version": "0.47.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.47.0.tgz", - "integrity": "sha512-XFWVx6k0XlU8lu6cBlCa29ONtVt6ADEjmxtyAyeF2+rifk8uBJbk1La0yIVfI0DoKURGbaEDTNelaXG9l/lNNQ==", + "version": "0.47.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.47.1.tgz", + "integrity": "sha512-QNXPTWteDclR2B4pDFpz0TNghgB33UMjUt14B+BZPmtH1MwUFAfLHBaP5If0Z5NZC+jaH8oF2glgYjrmhZWmSw==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { @@ -3043,13 +3653,13 @@ } }, "node_modules/@opentelemetry/instrumentation-fastify": { - "version": "0.44.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fastify/-/instrumentation-fastify-0.44.1.tgz", - "integrity": "sha512-RoVeMGKcNttNfXMSl6W4fsYoCAYP1vi6ZAWIGhBY+o7R9Y0afA7f9JJL0j8LHbyb0P0QhSYk+6O56OwI2k4iRQ==", + "version": "0.44.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fastify/-/instrumentation-fastify-0.44.2.tgz", + "integrity": "sha512-arSp97Y4D2NWogoXRb8CzFK3W2ooVdvqRRtQDljFt9uC3zI6OuShgey6CVFC0JxT1iGjkAr1r4PDz23mWrFULQ==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { @@ -3060,13 +3670,13 @@ } }, "node_modules/@opentelemetry/instrumentation-fs": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.19.0.tgz", - "integrity": "sha512-JGwmHhBkRT2G/BYNV1aGI+bBjJu4fJUD/5/Jat0EWZa2ftrLV3YE8z84Fiij/wK32oMZ88eS8DI4ecLGZhpqsQ==", + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.19.1.tgz", + "integrity": "sha512-6g0FhB3B9UobAR60BGTcXg4IHZ6aaYJzp0Ki5FhnxyAPt8Ns+9SSvgcrnsN2eGmk3RWG5vYycUGOEApycQL24A==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.57.0" + "@opentelemetry/instrumentation": "^0.57.1" }, "engines": { "node": ">=14" @@ -3076,12 +3686,12 @@ } }, "node_modules/@opentelemetry/instrumentation-generic-pool": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.43.0.tgz", - "integrity": "sha512-at8GceTtNxD1NfFKGAuwtqM41ot/TpcLh+YsGe4dhf7gvv1HW/ZWdq6nfRtS6UjIvZJOokViqLPJ3GVtZItAnQ==", + "version": "0.43.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.43.1.tgz", + "integrity": "sha512-M6qGYsp1cURtvVLGDrPPZemMFEbuMmCXgQYTReC/IbimV5sGrLBjB+/hANUpRZjX67nGLdKSVLZuQQAiNz+sww==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0" + "@opentelemetry/instrumentation": "^0.57.1" }, "engines": { "node": ">=14" @@ -3091,12 +3701,12 @@ } }, "node_modules/@opentelemetry/instrumentation-graphql": { - "version": "0.47.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.47.0.tgz", - "integrity": "sha512-Cc8SMf+nLqp0fi8oAnooNEfwZWFnzMiBHCGmDFYqmgjPylyLmi83b+NiTns/rKGwlErpW0AGPt0sMpkbNlzn8w==", + "version": "0.47.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.47.1.tgz", + "integrity": "sha512-EGQRWMGqwiuVma8ZLAZnExQ7sBvbOx0N/AE/nlafISPs8S+QtXX+Viy6dcQwVWwYHQPAcuY3bFt3xgoAwb4ZNQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0" + "@opentelemetry/instrumentation": "^0.57.1" }, "engines": { "node": ">=14" @@ -3106,12 +3716,12 @@ } }, "node_modules/@opentelemetry/instrumentation-grpc": { - "version": "0.57.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-grpc/-/instrumentation-grpc-0.57.1.tgz", - "integrity": "sha512-tZ0LO6hxLCnQfSS03BpYWc+kZpqFJJUbYb+GfEr5YJ1/YrOtRP8lCpC8AC1QIVmqGn+Vlxjkn3tSifNHsk9enw==", + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-grpc/-/instrumentation-grpc-0.57.2.tgz", + "integrity": "sha512-TR6YQA67cLSZzdxbf2SrbADJy2Y8eBW1+9mF15P0VK2MYcpdoUSmQTF1oMkBwa3B9NwqDFA2fq7wYTTutFQqaQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/instrumentation": "0.57.1", + "@opentelemetry/instrumentation": "0.57.2", "@opentelemetry/semantic-conventions": "1.28.0" }, "engines": { @@ -3122,13 +3732,13 @@ } }, "node_modules/@opentelemetry/instrumentation-hapi": { - "version": "0.45.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.45.1.tgz", - "integrity": "sha512-VH6mU3YqAKTePPfUPwfq4/xr049774qWtfTuJqVHoVspCLiT3bW+fCQ1toZxt6cxRPYASoYaBsMA3CWo8B8rcw==", + "version": "0.45.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.45.2.tgz", + "integrity": "sha512-7Ehow/7Wp3aoyCrZwQpU7a2CnoMq0XhIcioFuKjBb0PLYfBfmTsFTUyatlHu0fRxhwcRsSQRTvEhmZu8CppBpQ==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { @@ -3139,13 +3749,13 @@ } }, "node_modules/@opentelemetry/instrumentation-http": { - "version": "0.57.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.57.1.tgz", - "integrity": "sha512-ThLmzAQDs7b/tdKI3BV2+yawuF09jF111OFsovqT1Qj3D8vjwKBwhi/rDE5xethwn4tSXtZcJ9hBsVAlWFQZ7g==", + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.57.2.tgz", + "integrity": "sha512-1Uz5iJ9ZAlFOiPuwYg29Bf7bJJc/GeoeJIFKJYQf67nTVKFe8RHbEtxgkOmK4UGZNHKXcpW4P8cWBYzBn1USpg==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "1.30.1", - "@opentelemetry/instrumentation": "0.57.1", + "@opentelemetry/instrumentation": "0.57.2", "@opentelemetry/semantic-conventions": "1.28.0", "forwarded-parse": "2.1.2", "semver": "^7.5.2" @@ -3158,12 +3768,12 @@ } }, "node_modules/@opentelemetry/instrumentation-ioredis": { - "version": "0.47.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.47.0.tgz", - "integrity": "sha512-4HqP9IBC8e7pW9p90P3q4ox0XlbLGme65YTrA3UTLvqvo4Z6b0puqZQP203YFu8m9rE/luLfaG7/xrwwqMUpJw==", + "version": "0.47.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.47.1.tgz", + "integrity": "sha512-OtFGSN+kgk/aoKgdkKQnBsQFDiG8WdCxu+UrHr0bXScdAmtSzLSraLo7wFIb25RVHfRWvzI5kZomqJYEg/l1iA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/redis-common": "^0.36.2", "@opentelemetry/semantic-conventions": "^1.27.0" }, @@ -3175,12 +3785,12 @@ } }, "node_modules/@opentelemetry/instrumentation-kafkajs": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.7.0.tgz", - "integrity": "sha512-LB+3xiNzc034zHfCtgs4ITWhq6Xvdo8bsq7amR058jZlf2aXXDrN9SV4si4z2ya9QX4tz6r4eZJwDkXOp14/AQ==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.7.1.tgz", + "integrity": "sha512-OtjaKs8H7oysfErajdYr1yuWSjMAectT7Dwr+axIoZqT9lmEOkD/H/3rgAs8h/NIuEi2imSXD+vL4MZtOuJfqQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { @@ -3191,12 +3801,12 @@ } }, "node_modules/@opentelemetry/instrumentation-knex": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.44.0.tgz", - "integrity": "sha512-SlT0+bLA0Lg3VthGje+bSZatlGHw/vwgQywx0R/5u9QC59FddTQSPJeWNw29M6f8ScORMeUOOTwihlQAn4GkJQ==", + "version": "0.44.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.44.1.tgz", + "integrity": "sha512-U4dQxkNhvPexffjEmGwCq68FuftFK15JgUF05y/HlK3M6W/G2iEaACIfXdSnwVNe9Qh0sPfw8LbOPxrWzGWGMQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { @@ -3207,13 +3817,13 @@ } }, "node_modules/@opentelemetry/instrumentation-koa": { - "version": "0.47.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.47.0.tgz", - "integrity": "sha512-HFdvqf2+w8sWOuwtEXayGzdZ2vWpCKEQv5F7+2DSA74Te/Cv4rvb2E5So5/lh+ok4/RAIPuvCbCb/SHQFzMmbw==", + "version": "0.47.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.47.1.tgz", + "integrity": "sha512-l/c+Z9F86cOiPJUllUCt09v+kICKvT+Vg1vOAJHtHPsJIzurGayucfCMq2acd/A/yxeNWunl9d9eqZ0G+XiI6A==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { @@ -3224,12 +3834,12 @@ } }, "node_modules/@opentelemetry/instrumentation-lru-memoizer": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.44.0.tgz", - "integrity": "sha512-Tn7emHAlvYDFik3vGU0mdwvWJDwtITtkJ+5eT2cUquct6nIs+H8M47sqMJkCpyPe5QIBJoTOHxmc6mj9lz6zDw==", + "version": "0.44.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.44.1.tgz", + "integrity": "sha512-5MPkYCvG2yw7WONEjYj5lr5JFehTobW7wX+ZUFy81oF2lr9IPfZk9qO+FTaM0bGEiymwfLwKe6jE15nHn1nmHg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0" + "@opentelemetry/instrumentation": "^0.57.1" }, "engines": { "node": ">=14" @@ -3239,12 +3849,12 @@ } }, "node_modules/@opentelemetry/instrumentation-memcached": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-memcached/-/instrumentation-memcached-0.43.0.tgz", - "integrity": "sha512-qjldZMBpfxKwI4ODytX6raF1WE+Qov0wTW4+tkofjas1b8e0WmVs+Pw4/YlmjJNOKRLD1usYkP7QlmPLvyzZSA==", + "version": "0.43.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-memcached/-/instrumentation-memcached-0.43.1.tgz", + "integrity": "sha512-rK5YWC22gmsLp2aEbaPk5F+9r6BFFZuc9GTnW/ErrWpz2XNHUgeFInoPDg4t+Trs8OttIfn8XwkfFkSKqhxanw==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/semantic-conventions": "^1.27.0", "@types/memcached": "^2.2.6" }, @@ -3256,12 +3866,12 @@ } }, "node_modules/@opentelemetry/instrumentation-mongodb": { - "version": "0.51.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.51.0.tgz", - "integrity": "sha512-cMKASxCX4aFxesoj3WK8uoQ0YUrRvnfxaO72QWI2xLu5ZtgX/QvdGBlU3Ehdond5eb74c2s1cqRQUIptBnKz1g==", + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.52.0.tgz", + "integrity": "sha512-1xmAqOtRUQGR7QfJFfGV/M2kC7wmI2WgZdpru8hJl3S0r4hW0n3OQpEHlSGXJAaNFyvT+ilnwkT+g5L4ljHR6g==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { @@ -3272,13 +3882,13 @@ } }, "node_modules/@opentelemetry/instrumentation-mongoose": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.46.0.tgz", - "integrity": "sha512-mtVv6UeaaSaWTeZtLo4cx4P5/ING2obSqfWGItIFSunQBrYROfhuVe7wdIrFUs2RH1tn2YYpAJyMaRe/bnTTIQ==", + "version": "0.46.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.46.1.tgz", + "integrity": "sha512-3kINtW1LUTPkiXFRSSBmva1SXzS/72we/jL22N+BnF3DFcoewkdkHPYOIdAAk9gSicJ4d5Ojtt1/HeibEc5OQg==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { @@ -3289,12 +3899,12 @@ } }, "node_modules/@opentelemetry/instrumentation-mysql": { - "version": "0.45.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.45.0.tgz", - "integrity": "sha512-tWWyymgwYcTwZ4t8/rLDfPYbOTF3oYB8SxnYMtIQ1zEf5uDm90Ku3i6U/vhaMyfHNlIHvDhvJh+qx5Nc4Z3Acg==", + "version": "0.45.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.45.1.tgz", + "integrity": "sha512-TKp4hQ8iKQsY7vnp/j0yJJ4ZsP109Ht6l4RHTj0lNEG1TfgTrIH5vJMbgmoYXWzNHAqBH2e7fncN12p3BP8LFg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/semantic-conventions": "^1.27.0", "@types/mysql": "2.15.26" }, @@ -3306,12 +3916,12 @@ } }, "node_modules/@opentelemetry/instrumentation-mysql2": { - "version": "0.45.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.45.0.tgz", - "integrity": "sha512-qLslv/EPuLj0IXFvcE3b0EqhWI8LKmrgRPIa4gUd8DllbBpqJAvLNJSv3cC6vWwovpbSI3bagNO/3Q2SuXv2xA==", + "version": "0.45.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.45.2.tgz", + "integrity": "sha512-h6Ad60FjCYdJZ5DTz1Lk2VmQsShiViKe0G7sYikb0GHI0NVvApp2XQNRHNjEMz87roFttGPLHOYVPlfy+yVIhQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/semantic-conventions": "^1.27.0", "@opentelemetry/sql-common": "^0.40.1" }, @@ -3323,12 +3933,12 @@ } }, "node_modules/@opentelemetry/instrumentation-nestjs-core": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.44.0.tgz", - "integrity": "sha512-t16pQ7A4WYu1yyQJZhRKIfUNvl5PAaF2pEteLvgJb/BWdd1oNuU1rOYt4S825kMy+0q4ngiX281Ss9qiwHfxFQ==", + "version": "0.44.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.44.1.tgz", + "integrity": "sha512-4TXaqJK27QXoMqrt4+hcQ6rKFd8B6V4JfrTJKnqBmWR1cbaqd/uwyl9yxhNH1JEkyo8GaBfdpBC4ZE4FuUhPmg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { @@ -3339,12 +3949,12 @@ } }, "node_modules/@opentelemetry/instrumentation-net": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-net/-/instrumentation-net-0.43.0.tgz", - "integrity": "sha512-jFzYpCGg1+s4uePNC86GcdzsYzDZpfVMDsHNZzw5MX6tMWyc2jtiXBFWed41HpWOtkIRU/SJd7KR0k1WjNZRuQ==", + "version": "0.43.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-net/-/instrumentation-net-0.43.1.tgz", + "integrity": "sha512-TaMqP6tVx9/SxlY81dHlSyP5bWJIKq+K7vKfk4naB/LX4LBePPY3++1s0edpzH+RfwN+tEGVW9zTb9ci0up/lQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { @@ -3355,14 +3965,14 @@ } }, "node_modules/@opentelemetry/instrumentation-pg": { - "version": "0.50.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.50.0.tgz", - "integrity": "sha512-TtLxDdYZmBhFswm8UIsrDjh/HFBeDXd4BLmE8h2MxirNHewLJ0VS9UUddKKEverb5Sm2qFVjqRjcU+8Iw4FJ3w==", + "version": "0.51.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.51.1.tgz", + "integrity": "sha512-QxgjSrxyWZc7Vk+qGSfsejPVFL1AgAJdSBMYZdDUbwg730D09ub3PXScB9d04vIqPriZ+0dqzjmQx0yWKiCi2Q==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^1.26.0", - "@opentelemetry/instrumentation": "^0.57.0", - "@opentelemetry/semantic-conventions": "1.27.0", + "@opentelemetry/instrumentation": "^0.57.1", + "@opentelemetry/semantic-conventions": "^1.27.0", "@opentelemetry/sql-common": "^0.40.1", "@types/pg": "8.6.1", "@types/pg-pool": "2.0.6" @@ -3374,24 +3984,15 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation-pg/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", - "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/instrumentation-pino": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pino/-/instrumentation-pino-0.46.0.tgz", - "integrity": "sha512-TFjW24fwc/5KafDZuXbdViGiTym/6U6tDnOEkM5K9LIKsySMWb8xNIVE7y/6B8zDwImncEssNN1t42NixQJqug==", + "version": "0.46.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pino/-/instrumentation-pino-0.46.1.tgz", + "integrity": "sha512-HB8gD/9CNAKlTV+mdZehnFC4tLUtQ7e+729oGq88e4WipxzZxmMYuRwZ2vzOA9/APtq+MRkERJ9PcoDqSIjZ+g==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "^0.57.0", + "@opentelemetry/api-logs": "^0.57.1", "@opentelemetry/core": "^1.25.0", - "@opentelemetry/instrumentation": "^0.57.0" + "@opentelemetry/instrumentation": "^0.57.1" }, "engines": { "node": ">=14" @@ -3401,12 +4002,12 @@ } }, "node_modules/@opentelemetry/instrumentation-redis": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.46.0.tgz", - "integrity": "sha512-dXgSf+h+v3Bl4/NYzcSHG0NtqbXz74ph9J1ZBwxTnaB79u+C+ntfqtNt9jklIEAEZ1jR0jRCsVbiZyOpoCpTOg==", + "version": "0.46.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.46.1.tgz", + "integrity": "sha512-AN7OvlGlXmlvsgbLHs6dS1bggp6Fcki+GxgYZdSrb/DB692TyfjR7sVILaCe0crnP66aJuXsg9cge3hptHs9UA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/redis-common": "^0.36.2", "@opentelemetry/semantic-conventions": "^1.27.0" }, @@ -3418,12 +4019,12 @@ } }, "node_modules/@opentelemetry/instrumentation-redis-4": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis-4/-/instrumentation-redis-4-0.46.0.tgz", - "integrity": "sha512-aTUWbzbFMFeRODn3720TZO0tsh/49T8H3h8vVnVKJ+yE36AeW38Uj/8zykQ/9nO8Vrtjr5yKuX3uMiG/W8FKNw==", + "version": "0.46.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis-4/-/instrumentation-redis-4-0.46.1.tgz", + "integrity": "sha512-UMqleEoabYMsWoTkqyt9WAzXwZ4BlFZHO40wr3d5ZvtjKCHlD4YXLm+6OLCeIi/HkX7EXvQaz8gtAwkwwSEvcQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/redis-common": "^0.36.2", "@opentelemetry/semantic-conventions": "^1.27.0" }, @@ -3435,13 +4036,13 @@ } }, "node_modules/@opentelemetry/instrumentation-restify": { - "version": "0.45.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-restify/-/instrumentation-restify-0.45.0.tgz", - "integrity": "sha512-CJ5vq14Plh4W4382Jd/jpNEJStqwqbCzZH1Op4EZVPxXhYOwCafgyflOqjxXSzTvqzhaPDT+A079ix5ebQUlYw==", + "version": "0.45.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-restify/-/instrumentation-restify-0.45.1.tgz", + "integrity": "sha512-Zd6Go9iEa+0zcoA2vDka9r/plYKaT3BhD3ESIy4JNIzFWXeQBGbH3zZxQIsz0jbNTMEtonlymU7eTLeaGWiApA==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { @@ -3452,12 +4053,12 @@ } }, "node_modules/@opentelemetry/instrumentation-router": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-router/-/instrumentation-router-0.44.0.tgz", - "integrity": "sha512-rmQZKYcof4M6vQjwtrlfybQo7BSD0mxkXdhfNHWxFjxOFGw9i7EuXSYLnThcVAqNnJ1EljzZiHzaJiq5Ehcb3A==", + "version": "0.44.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-router/-/instrumentation-router-0.44.1.tgz", + "integrity": "sha512-l4T/S7ByjpY5TCUPeDe1GPns02/5BpR0jroSMexyH3ZnXJt9PtYqx1IKAlOjaFEGEOQF2tGDsMi4PY5l+fSniQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { @@ -3468,12 +4069,12 @@ } }, "node_modules/@opentelemetry/instrumentation-socket.io": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-socket.io/-/instrumentation-socket.io-0.46.0.tgz", - "integrity": "sha512-BU3XGT63ziF0S9Ky0YevCuMhHUq6U+Wi1g/piJcB16nOqlfd1SW6EACl5LrUe+aNZk2qIXfuS7YV8R+H99+XQQ==", + "version": "0.46.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-socket.io/-/instrumentation-socket.io-0.46.1.tgz", + "integrity": "sha512-9AsCVUAHOqvfe2RM/2I0DsDnx2ihw1d5jIN4+Bly1YPFTJIbk4+bXjAkr9+X6PUfhiV5urQHZkiYYPU1Q4yzPA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { @@ -3484,12 +4085,12 @@ } }, "node_modules/@opentelemetry/instrumentation-tedious": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.18.0.tgz", - "integrity": "sha512-9zhjDpUDOtD+coeADnYEJQ0IeLVCj7w/hqzIutdp5NqS1VqTAanaEfsEcSypyvYv5DX3YOsTUoF+nr2wDXPETA==", + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.18.1.tgz", + "integrity": "sha512-5Cuy/nj0HBaH+ZJ4leuD7RjgvA844aY2WW+B5uLcWtxGjRZl3MNLuxnNg5DYWZNPO+NafSSnra0q49KWAHsKBg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/semantic-conventions": "^1.27.0", "@types/tedious": "^4.0.14" }, @@ -3501,13 +4102,13 @@ } }, "node_modules/@opentelemetry/instrumentation-undici": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.10.0.tgz", - "integrity": "sha512-vm+V255NGw9gaSsPD6CP0oGo8L55BffBc8KnxqsMuc6XiAD1L8SFNzsW0RHhxJFqy9CJaJh+YiJ5EHXuZ5rZBw==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.10.1.tgz", + "integrity": "sha512-rkOGikPEyRpMCmNu9AQuV5dtRlDmJp2dK5sw8roVshAGoB6hH/3QjDtRhdwd75SsJwgynWUNRUYe0wAkTo16tQ==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.57.0" + "@opentelemetry/instrumentation": "^0.57.1" }, "engines": { "node": ">=14" @@ -3517,13 +4118,13 @@ } }, "node_modules/@opentelemetry/instrumentation-winston": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-winston/-/instrumentation-winston-0.44.0.tgz", - "integrity": "sha512-2uIrdmDIU9qJuHHKXTI3Gef+tNQmKtcwXDA6S0tm+KpKgkMwZB6AC0rNmGNQsxbGJSORj0NJvy5TVvk6jjsaqg==", + "version": "0.44.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-winston/-/instrumentation-winston-0.44.1.tgz", + "integrity": "sha512-iexblTsT3fP0hHUz/M1mWr+Ylg3bsYN2En/jvKXZtboW3Qkvt17HrQJYTF9leVIkXAfN97QxAcTE99YGbQW7vQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "^0.57.0", - "@opentelemetry/instrumentation": "^0.57.0" + "@opentelemetry/api-logs": "^0.57.1", + "@opentelemetry/instrumentation": "^0.57.1" }, "engines": { "node": ">=14" @@ -3533,13 +4134,13 @@ } }, "node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.57.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.57.1.tgz", - "integrity": "sha512-GNBJAEYfeiYJQ3O2dvXgiNZ/qjWrBxSb1L1s7iV/jKBRGMN3Nv+miTk2SLeEobF5E5ZK4rVcHKlBZ71bPVIv/g==", + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.57.2.tgz", + "integrity": "sha512-XdxEzL23Urhidyebg5E6jZoaiW5ygP/mRjxLHixogbqwDy2Faduzb5N0o/Oi+XTIJu+iyxXdVORjXax+Qgfxag==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "1.30.1", - "@opentelemetry/otlp-transformer": "0.57.1" + "@opentelemetry/otlp-transformer": "0.57.2" }, "engines": { "node": ">=14" @@ -3549,15 +4150,15 @@ } }, "node_modules/@opentelemetry/otlp-grpc-exporter-base": { - "version": "0.57.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.57.1.tgz", - "integrity": "sha512-wWflmkDhH/3wf6yEqPmzmqA6r+A8+LQABfIVZC0jDGtWVJj6eCWcGHU41UxupMbbsgjZRLYtWDilaCHOjmR7gg==", + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.57.2.tgz", + "integrity": "sha512-USn173KTWy0saqqRB5yU9xUZ2xdgb1Rdu5IosJnm9aV4hMTuFFRTUsQxbgc24QxpCHeoKzzCSnS/JzdV0oM2iQ==", "license": "Apache-2.0", "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "1.30.1", - "@opentelemetry/otlp-exporter-base": "0.57.1", - "@opentelemetry/otlp-transformer": "0.57.1" + "@opentelemetry/otlp-exporter-base": "0.57.2", + "@opentelemetry/otlp-transformer": "0.57.2" }, "engines": { "node": ">=14" @@ -3567,15 +4168,15 @@ } }, "node_modules/@opentelemetry/otlp-transformer": { - "version": "0.57.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.57.1.tgz", - "integrity": "sha512-EX67y+ukNNfFrOLyjYGw8AMy0JPIlEX1dW60SGUNZWW2hSQyyolX7EqFuHP5LtXLjJHNfzx5SMBVQ3owaQCNDw==", + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.57.2.tgz", + "integrity": "sha512-48IIRj49gbQVK52jYsw70+Jv+JbahT8BqT2Th7C4H7RCM9d0gZ5sgNPoMpWldmfjvIsSgiGJtjfk9MeZvjhoig==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.57.1", + "@opentelemetry/api-logs": "0.57.2", "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1", - "@opentelemetry/sdk-logs": "0.57.1", + "@opentelemetry/sdk-logs": "0.57.2", "@opentelemetry/sdk-metrics": "1.30.1", "@opentelemetry/sdk-trace-base": "1.30.1", "protobufjs": "^7.3.0" @@ -3588,9 +4189,9 @@ } }, "node_modules/@opentelemetry/propagation-utils": { - "version": "0.30.15", - "resolved": "https://registry.npmjs.org/@opentelemetry/propagation-utils/-/propagation-utils-0.30.15.tgz", - "integrity": "sha512-nQ30K+eXTkd9Kt8yep9FPrqogS712GvdkV6R1T+xZMSZnFrRCyZuWxMtP3+s3hrK2HWw3ti4lsIfBzsHWYiyrA==", + "version": "0.30.16", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagation-utils/-/propagation-utils-0.30.16.tgz", + "integrity": "sha512-ZVQ3Z/PQ+2GQlrBfbMMMT0U7MzvYZLCPP800+ooyaBqm4hMvuQHfP028gB9/db0mwkmyEAMad9houukUVxhwcw==", "license": "Apache-2.0", "engines": { "node": ">=14" @@ -3639,9 +4240,9 @@ } }, "node_modules/@opentelemetry/resource-detector-alibaba-cloud": { - "version": "0.30.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-alibaba-cloud/-/resource-detector-alibaba-cloud-0.30.0.tgz", - "integrity": "sha512-CniMuVcJENb7e6ljXC8BuE8xyHKV6kjHjFzAjbeK7BIq2JSPOqfvC+jjhUYnnSGFnDyoZxJCIbt6XIdwPWRPhg==", + "version": "0.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-alibaba-cloud/-/resource-detector-alibaba-cloud-0.30.1.tgz", + "integrity": "sha512-9l0FVP3F4+Z6ax27vMzkmhZdNtxAbDqEfy7rduzya3xFLaRiJSvOpw6cru6Edl5LwO+WvgNui+VzHa9ViE8wCg==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^1.26.0", @@ -3656,9 +4257,9 @@ } }, "node_modules/@opentelemetry/resource-detector-aws": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-aws/-/resource-detector-aws-1.11.0.tgz", - "integrity": "sha512-j7qQ75enAJrlSPkPowasScuukZ2ffFG659rhxOpUM4dBe/O8Jpq+dy4pIdFtjWKkM9i7LgisdUt/GW7wGIWoEQ==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-aws/-/resource-detector-aws-1.12.0.tgz", + "integrity": "sha512-Cvi7ckOqiiuWlHBdA1IjS0ufr3sltex2Uws2RK6loVp4gzIJyOijsddAI6IZ5kiO8h/LgCWe8gxPmwkTKImd+Q==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^1.0.0", @@ -3673,9 +4274,9 @@ } }, "node_modules/@opentelemetry/resource-detector-azure": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-azure/-/resource-detector-azure-0.6.0.tgz", - "integrity": "sha512-cQbR/x9IhCYk47GWt4uC1G5yQN8JJ02Ec8uT38fj7uIXRbAARulwGr7Ax0dUo0eAtXEKQ+fXdzkLR1Am8cw4mg==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-azure/-/resource-detector-azure-0.6.1.tgz", + "integrity": "sha512-Djr31QCExVfWViaf9cGJnH+bUInD72p0GEfgDGgjCAztyvyji6WJvKjs6qmkpPN+Ig6KLk0ho2VgzT5Kdl4L2Q==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^1.25.1", @@ -3690,9 +4291,9 @@ } }, "node_modules/@opentelemetry/resource-detector-container": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-container/-/resource-detector-container-0.6.0.tgz", - "integrity": "sha512-HxOzOsGlIjAbnTjwRBWQOsjrQIZ4NnQaaBc6noO8fW0v9ahyRxzwDFVr/3X1kSYLnpr2RGeWmMGDX6VcHECsLA==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-container/-/resource-detector-container-0.6.1.tgz", + "integrity": "sha512-o4sLzx149DQXDmVa8pgjBDEEKOj9SuQnkSLbjUVOpQNnn10v0WNR6wLwh30mFsK26xOJ6SpqZBGKZiT7i5MjlA==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^1.26.0", @@ -3707,9 +4308,9 @@ } }, "node_modules/@opentelemetry/resource-detector-gcp": { - "version": "0.33.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-gcp/-/resource-detector-gcp-0.33.0.tgz", - "integrity": "sha512-y368hq2UM6j42Py7xlR4rTfl+wC4CdGNGT38nqW+6BwGTQso0NC/GeifcwqorEKs/JWU9azA6XNDyUBNEjFpGA==", + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-gcp/-/resource-detector-gcp-0.33.1.tgz", + "integrity": "sha512-/aZJXI1rU6Eus04ih2vU0hxXAibXXMzH1WlDZ8bXcTJmhwmTY8cP392+6l7cWeMnTQOibBUz8UKV3nhcCBAefw==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "^1.0.0", @@ -3741,12 +4342,12 @@ } }, "node_modules/@opentelemetry/sdk-logs": { - "version": "0.57.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.57.1.tgz", - "integrity": "sha512-jGdObb/BGWu6Peo3cL3skx/Rl1Ak/wDDO3vpPrrThGbqE7isvkCsX6uE+OAt8Ayjm9YC8UGkohWbLR09JmM0FA==", + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.57.2.tgz", + "integrity": "sha512-TXFHJ5c+BKggWbdEQ/inpgIzEmS2BGQowLE9UhsMd7YYlUfBQJ4uax0VF/B5NYigdM/75OoJGhAV3upEhK+3gg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.57.1", + "@opentelemetry/api-logs": "0.57.2", "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1" }, @@ -3774,27 +4375,27 @@ } }, "node_modules/@opentelemetry/sdk-node": { - "version": "0.57.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-node/-/sdk-node-0.57.1.tgz", - "integrity": "sha512-0i25YQCpNiE1RDiaZ6ECO3Hgd6DIJeyHyA2AY9C4szMdZV5cM2m8/nrwK6fyNZdOEjRd54D/FkyP3aqZVIPGvg==", + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-node/-/sdk-node-0.57.2.tgz", + "integrity": "sha512-8BaeqZyN5sTuPBtAoY+UtKwXBdqyuRKmekN5bFzAO40CgbGzAxfTpiL3PBerT7rhZ7p2nBdq7FaMv/tBQgHE4A==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.57.1", + "@opentelemetry/api-logs": "0.57.2", "@opentelemetry/core": "1.30.1", - "@opentelemetry/exporter-logs-otlp-grpc": "0.57.1", - "@opentelemetry/exporter-logs-otlp-http": "0.57.1", - "@opentelemetry/exporter-logs-otlp-proto": "0.57.1", - "@opentelemetry/exporter-metrics-otlp-grpc": "0.57.1", - "@opentelemetry/exporter-metrics-otlp-http": "0.57.1", - "@opentelemetry/exporter-metrics-otlp-proto": "0.57.1", - "@opentelemetry/exporter-prometheus": "0.57.1", - "@opentelemetry/exporter-trace-otlp-grpc": "0.57.1", - "@opentelemetry/exporter-trace-otlp-http": "0.57.1", - "@opentelemetry/exporter-trace-otlp-proto": "0.57.1", + "@opentelemetry/exporter-logs-otlp-grpc": "0.57.2", + "@opentelemetry/exporter-logs-otlp-http": "0.57.2", + "@opentelemetry/exporter-logs-otlp-proto": "0.57.2", + "@opentelemetry/exporter-metrics-otlp-grpc": "0.57.2", + "@opentelemetry/exporter-metrics-otlp-http": "0.57.2", + "@opentelemetry/exporter-metrics-otlp-proto": "0.57.2", + "@opentelemetry/exporter-prometheus": "0.57.2", + "@opentelemetry/exporter-trace-otlp-grpc": "0.57.2", + "@opentelemetry/exporter-trace-otlp-http": "0.57.2", + "@opentelemetry/exporter-trace-otlp-proto": "0.57.2", "@opentelemetry/exporter-zipkin": "1.30.1", - "@opentelemetry/instrumentation": "0.57.1", + "@opentelemetry/instrumentation": "0.57.2", "@opentelemetry/resources": "1.30.1", - "@opentelemetry/sdk-logs": "0.57.1", + "@opentelemetry/sdk-logs": "0.57.2", "@opentelemetry/sdk-metrics": "1.30.1", "@opentelemetry/sdk-trace-base": "1.30.1", "@opentelemetry/sdk-trace-node": "1.30.1", @@ -3869,9 +4470,9 @@ } }, "node_modules/@photostructure/tz-lookup": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-11.0.0.tgz", - "integrity": "sha512-QMV5/dWtY/MdVPXZs/EApqzyhnqDq1keYEqpS+Xj2uidyaqw2Nk/fWcsszdruIXjdqp1VoWNzsgrO6bUHU1mFw==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-11.1.0.tgz", + "integrity": "sha512-UywyhMwUdVU2aH5ls7EweTEyPpXbDkgC//Nnsm/lWfpae8WX3N33Yy0/aBmb/Pd9+qEtgcFMYTtN/Htb+cd0ZA==", "license": "CC0-1.0" }, "node_modules/@pkgjs/parseargs": { @@ -4028,9 +4629,9 @@ } }, "node_modules/@react-email/components": { - "version": "0.0.32", - "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.32.tgz", - "integrity": "sha512-+1Wv7PyVgWfLoj5W0+CvBsJMIfMI6ibcFcIPXNkb2lhKQQASgxSoAedRL1rH0CCaBo6+63tg8y4baHzJonfZbw==", + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.33.tgz", + "integrity": "sha512-/GKdT3YijT1iEWPAXF644jr12w5xVgzUr0zlbZGt2KOkGeFHNZUCL5UtRopmnjrH/Fayf8Gjv6q/4E2cZgDtdQ==", "license": "MIT", "dependencies": { "@react-email/body": "0.0.11", @@ -4048,7 +4649,7 @@ "@react-email/link": "0.0.12", "@react-email/markdown": "0.0.14", "@react-email/preview": "0.0.12", - "@react-email/render": "1.0.4", + "@react-email/render": "1.0.5", "@react-email/row": "0.0.12", "@react-email/section": "0.0.16", "@react-email/tailwind": "1.0.4", @@ -4182,9 +4783,9 @@ } }, "node_modules/@react-email/render": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.0.4.tgz", - "integrity": "sha512-8ZXi89d8igBDE6W3zlHBa3GEDWKEUFDAa7i8MvVxnRViQuvsRbibK3ltuPgixxRI5+HgGNCSreBHQKZCkhUdyw==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.0.5.tgz", + "integrity": "sha512-CA69HYXPk21HhtAXATIr+9JJwpDNmAFCvdMUjWmeoD1+KhJ9NAxusMRxKNeibdZdslmq3edaeOKGbdQ9qjK8LQ==", "license": "MIT", "dependencies": { "html-to-text": "9.0.5", @@ -4199,6 +4800,21 @@ "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, + "node_modules/@react-email/render/node_modules/prettier": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", + "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/@react-email/row": { "version": "0.0.12", "resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.12.tgz", @@ -4271,9 +4887,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.28.1.tgz", - "integrity": "sha512-2aZp8AES04KI2dy3Ss6/MDjXbwBzj+i0GqKtWXgw2/Ma6E4jJvujryO6gJAghIRVz7Vwr9Gtl/8na3nDUKpraQ==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.9.tgz", + "integrity": "sha512-qZdlImWXur0CFakn2BJ2znJOdqYZKiedEPEVNTBrpfPjc/YuTGcaYZcdmNFTkUj3DU0ZM/AElcM8Ybww3xVLzA==", "cpu": [ "arm" ], @@ -4285,9 +4901,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.28.1.tgz", - "integrity": "sha512-EbkK285O+1YMrg57xVA+Dp0tDBRB93/BZKph9XhMjezf6F4TpYjaUSuPt5J0fZXlSag0LmZAsTmdGGqPp4pQFA==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.9.tgz", + "integrity": "sha512-4KW7P53h6HtJf5Y608T1ISKvNIYLWRKMvfnG0c44M6In4DQVU58HZFEVhWINDZKp7FZps98G3gxwC1sb0wXUUg==", "cpu": [ "arm64" ], @@ -4299,9 +4915,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.28.1.tgz", - "integrity": "sha512-prduvrMKU6NzMq6nxzQw445zXgaDBbMQvmKSJaxpaZ5R1QDM8w+eGxo6Y/jhT/cLoCvnZI42oEqf9KQNYz1fqQ==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.9.tgz", + "integrity": "sha512-0CY3/K54slrzLDjOA7TOjN1NuLKERBgk9nY5V34mhmuu673YNb+7ghaDUs6N0ujXR7fz5XaS5Aa6d2TNxZd0OQ==", "cpu": [ "arm64" ], @@ -4313,9 +4929,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.28.1.tgz", - "integrity": "sha512-WsvbOunsUk0wccO/TV4o7IKgloJ942hVFK1CLatwv6TJspcCZb9umQkPdvB7FihmdxgaKR5JyxDjWpCOp4uZlQ==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.9.tgz", + "integrity": "sha512-eOojSEAi/acnsJVYRxnMkPFqcxSMFfrw7r2iD9Q32SGkb/Q9FpUY1UlAu1DH9T7j++gZ0lHjnm4OyH2vCI7l7Q==", "cpu": [ "x64" ], @@ -4327,9 +4943,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.28.1.tgz", - "integrity": "sha512-HTDPdY1caUcU4qK23FeeGxCdJF64cKkqajU0iBnTVxS8F7H/7BewvYoG+va1KPSL63kQ1PGNyiwKOfReavzvNA==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.9.tgz", + "integrity": "sha512-2lzjQPJbN5UnHm7bHIUKFMulGTQwdvOkouJDpPysJS+QFBGDJqcfh+CxxtG23Ik/9tEvnebQiylYoazFMAgrYw==", "cpu": [ "arm64" ], @@ -4341,9 +4957,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.28.1.tgz", - "integrity": "sha512-m/uYasxkUevcFTeRSM9TeLyPe2QDuqtjkeoTpP9SW0XxUWfcYrGDMkO/m2tTw+4NMAF9P2fU3Mw4ahNvo7QmsQ==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.9.tgz", + "integrity": "sha512-SLl0hi2Ah2H7xQYd6Qaiu01kFPzQ+hqvdYSoOtHYg/zCIFs6t8sV95kaoqjzjFwuYQLtOI0RZre/Ke0nPaQV+g==", "cpu": [ "x64" ], @@ -4355,9 +4971,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.28.1.tgz", - "integrity": "sha512-QAg11ZIt6mcmzpNE6JZBpKfJaKkqTm1A9+y9O+frdZJEuhQxiugM05gnCWiANHj4RmbgeVJpTdmKRmH/a+0QbA==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.9.tgz", + "integrity": "sha512-88I+D3TeKItrw+Y/2ud4Tw0+3CxQ2kLgu3QvrogZ0OfkmX/DEppehus7L3TS2Q4lpB+hYyxhkQiYPJ6Mf5/dPg==", "cpu": [ "arm" ], @@ -4369,9 +4985,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.28.1.tgz", - "integrity": "sha512-dRP9PEBfolq1dmMcFqbEPSd9VlRuVWEGSmbxVEfiq2cs2jlZAl0YNxFzAQS2OrQmsLBLAATDMb3Z6MFv5vOcXg==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.9.tgz", + "integrity": "sha512-3qyfWljSFHi9zH0KgtEPG4cBXHDFhwD8kwg6xLfHQ0IWuH9crp005GfoUUh/6w9/FWGBwEHg3lxK1iHRN1MFlA==", "cpu": [ "arm" ], @@ -4383,9 +4999,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.28.1.tgz", - "integrity": "sha512-uGr8khxO+CKT4XU8ZUH1TTEUtlktK6Kgtv0+6bIFSeiSlnGJHG1tSFSjm41uQ9sAO/5ULx9mWOz70jYLyv1QkA==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.9.tgz", + "integrity": "sha512-6TZjPHjKZUQKmVKMUowF3ewHxctrRR09eYyvT5eFv8w/fXarEra83A2mHTVJLA5xU91aCNOUnM+DWFMSbQ0Nxw==", "cpu": [ "arm64" ], @@ -4397,9 +5013,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.28.1.tgz", - "integrity": "sha512-QF54q8MYGAqMLrX2t7tNpi01nvq5RI59UBNx+3+37zoKX5KViPo/gk2QLhsuqok05sSCRluj0D00LzCwBikb0A==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.9.tgz", + "integrity": "sha512-LD2fytxZJZ6xzOKnMbIpgzFOuIKlxVOpiMAXawsAZ2mHBPEYOnLRK5TTEsID6z4eM23DuO88X0Tq1mErHMVq0A==", "cpu": [ "arm64" ], @@ -4411,9 +5027,9 @@ ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.28.1.tgz", - "integrity": "sha512-vPul4uodvWvLhRco2w0GcyZcdyBfpfDRgNKU+p35AWEbJ/HPs1tOUrkSueVbBS0RQHAf/A+nNtDpvw95PeVKOA==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.9.tgz", + "integrity": "sha512-dRAgTfDsn0TE0HI6cmo13hemKpVHOEyeciGtvlBTkpx/F65kTvShtY/EVyZEIfxFkV5JJTuQ9tP5HGBS0hfxIg==", "cpu": [ "loong64" ], @@ -4425,9 +5041,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.28.1.tgz", - "integrity": "sha512-pTnTdBuC2+pt1Rmm2SV7JWRqzhYpEILML4PKODqLz+C7Ou2apEV52h19CR7es+u04KlqplggmN9sqZlekg3R1A==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.9.tgz", + "integrity": "sha512-PHcNOAEhkoMSQtMf+rJofwisZqaU8iQ8EaSps58f5HYll9EAY5BSErCZ8qBDMVbq88h4UxaNPlbrKqfWP8RfJA==", "cpu": [ "ppc64" ], @@ -4439,9 +5055,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.28.1.tgz", - "integrity": "sha512-vWXy1Nfg7TPBSuAncfInmAI/WZDd5vOklyLJDdIRKABcZWojNDY0NJwruY2AcnCLnRJKSaBgf/GiJfauu8cQZA==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.9.tgz", + "integrity": "sha512-Z2i0Uy5G96KBYKjeQFKbbsB54xFOL5/y1P5wNBsbXB8yE+At3oh0DVMjQVzCJRJSfReiB2tX8T6HUFZ2k8iaKg==", "cpu": [ "riscv64" ], @@ -4453,9 +5069,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.28.1.tgz", - "integrity": "sha512-/yqC2Y53oZjb0yz8PVuGOQQNOTwxcizudunl/tFs1aLvObTclTwZ0JhXF2XcPT/zuaymemCDSuuUPXJJyqeDOg==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.9.tgz", + "integrity": "sha512-U+5SwTMoeYXoDzJX5dhDTxRltSrIax8KWwfaaYcynuJw8mT33W7oOgz0a+AaXtGuvhzTr2tVKh5UO8GVANTxyQ==", "cpu": [ "s390x" ], @@ -4467,9 +5083,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.28.1.tgz", - "integrity": "sha512-fzgeABz7rrAlKYB0y2kSEiURrI0691CSL0+KXwKwhxvj92VULEDQLpBYLHpF49MSiPG4sq5CK3qHMnb9tlCjBw==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.9.tgz", + "integrity": "sha512-FwBHNSOjUTQLP4MG7y6rR6qbGw4MFeQnIBrMe161QGaQoBQLqSUEKlHIiVgF3g/mb3lxlxzJOpIBhaP+C+KP2A==", "cpu": [ "x64" ], @@ -4481,9 +5097,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.28.1.tgz", - "integrity": "sha512-xQTDVzSGiMlSshpJCtudbWyRfLaNiVPXt1WgdWTwWz9n0U12cI2ZVtWe/Jgwyv/6wjL7b66uu61Vg0POWVfz4g==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.9.tgz", + "integrity": "sha512-cYRpV4650z2I3/s6+5/LONkjIz8MBeqrk+vPXV10ORBnshpn8S32bPqQ2Utv39jCiDcO2eJTuSlPXpnvmaIgRA==", "cpu": [ "x64" ], @@ -4495,9 +5111,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.28.1.tgz", - "integrity": "sha512-wSXmDRVupJstFP7elGMgv+2HqXelQhuNf+IS4V+nUpNVi/GUiBgDmfwD0UGN3pcAnWsgKG3I52wMOBnk1VHr/A==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.9.tgz", + "integrity": "sha512-z4mQK9dAN6byRA/vsSgQiPeuO63wdiDxZ9yg9iyX2QTzKuQM7T4xlBoeUP/J8uiFkqxkcWndWi+W7bXdPbt27Q==", "cpu": [ "arm64" ], @@ -4509,9 +5125,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.28.1.tgz", - "integrity": "sha512-ZkyTJ/9vkgrE/Rk9vhMXhf8l9D+eAhbAVbsGsXKy2ohmJaWg0LPQLnIxRdRp/bKyr8tXuPlXhIoGlEB5XpJnGA==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.9.tgz", + "integrity": "sha512-KB48mPtaoHy1AwDNkAJfHXvHp24H0ryZog28spEs0V48l3H1fr4i37tiyHsgKZJnCmvxsbATdZGBpbmxTE3a9w==", "cpu": [ "ia32" ], @@ -4523,9 +5139,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.28.1.tgz", - "integrity": "sha512-ZvK2jBafvttJjoIdKm/Q/Bh7IJ1Ose9IBOwpOXcOvW3ikGTQGmKDgxTC6oCAzW6PynbkKP8+um1du81XJHZ0JA==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.9.tgz", + "integrity": "sha512-AyleYRPU7+rgkMWbEh71fQlrzRfeP6SyMnRf9XX4fCdDPAJumdSBqYEcWPMzVQ4ScAl7E4oFfK0GUVn77xSwbw==", "cpu": [ "x64" ], @@ -4624,15 +5240,15 @@ "license": "MIT" }, "node_modules/@swc/core": { - "version": "1.10.7", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.10.7.tgz", - "integrity": "sha512-py91kjI1jV5D5W/Q+PurBdGsdU5TFbrzamP7zSCqLdMcHkKi3rQEM5jkQcZr0MXXSJTaayLxS3MWYTBIkzPDrg==", + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.11.8.tgz", + "integrity": "sha512-UAL+EULxrc0J73flwYHfu29mO8CONpDJiQv1QPDXsyCvDUcEhqAqUROVTgC+wtJCFFqMQdyr4stAA5/s0KSOmA==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.17" + "@swc/types": "^0.1.19" }, "engines": { "node": ">=10" @@ -4642,16 +5258,16 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.10.7", - "@swc/core-darwin-x64": "1.10.7", - "@swc/core-linux-arm-gnueabihf": "1.10.7", - "@swc/core-linux-arm64-gnu": "1.10.7", - "@swc/core-linux-arm64-musl": "1.10.7", - "@swc/core-linux-x64-gnu": "1.10.7", - "@swc/core-linux-x64-musl": "1.10.7", - "@swc/core-win32-arm64-msvc": "1.10.7", - "@swc/core-win32-ia32-msvc": "1.10.7", - "@swc/core-win32-x64-msvc": "1.10.7" + "@swc/core-darwin-arm64": "1.11.8", + "@swc/core-darwin-x64": "1.11.8", + "@swc/core-linux-arm-gnueabihf": "1.11.8", + "@swc/core-linux-arm64-gnu": "1.11.8", + "@swc/core-linux-arm64-musl": "1.11.8", + "@swc/core-linux-x64-gnu": "1.11.8", + "@swc/core-linux-x64-musl": "1.11.8", + "@swc/core-win32-arm64-msvc": "1.11.8", + "@swc/core-win32-ia32-msvc": "1.11.8", + "@swc/core-win32-x64-msvc": "1.11.8" }, "peerDependencies": { "@swc/helpers": "*" @@ -4663,9 +5279,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.10.7", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.10.7.tgz", - "integrity": "sha512-SI0OFg987P6hcyT0Dbng3YRISPS9uhLX1dzW4qRrfqQdb0i75lPJ2YWe9CN47HBazrIA5COuTzrD2Dc0TcVsSQ==", + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.8.tgz", + "integrity": "sha512-rrSsunyJWpHN+5V1zumndwSSifmIeFQBK9i2RMQQp15PgbgUNxHK5qoET1n20pcUrmZeT6jmJaEWlQchkV//Og==", "cpu": [ "arm64" ], @@ -4680,9 +5296,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.10.7", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.10.7.tgz", - "integrity": "sha512-RFIAmWVicD/l3RzxgHW0R/G1ya/6nyMspE2cAeDcTbjHi0I5qgdhBWd6ieXOaqwEwiCd0Mot1g2VZrLGoBLsjQ==", + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.11.8.tgz", + "integrity": "sha512-44goLqQuuo0HgWnG8qC+ZFw/qnjCVVeqffhzFr9WAXXotogVaxM8ze6egE58VWrfEc8me8yCcxOYL9RbtjhS/Q==", "cpu": [ "x64" ], @@ -4697,9 +5313,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.10.7", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.10.7.tgz", - "integrity": "sha512-QP8vz7yELWfop5mM5foN6KkLylVO7ZUgWSF2cA0owwIaziactB2hCPZY5QU690coJouk9KmdFsPWDnaCFUP8tg==", + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.8.tgz", + "integrity": "sha512-Mzo8umKlhTWwF1v8SLuTM1z2A+P43UVhf4R8RZDhzIRBuB2NkeyE+c0gexIOJBuGSIATryuAF4O4luDu727D1w==", "cpu": [ "arm" ], @@ -4714,9 +5330,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.10.7", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.10.7.tgz", - "integrity": "sha512-NgUDBGQcOeLNR+EOpmUvSDIP/F7i/OVOKxst4wOvT5FTxhnkWrW+StJGKj+DcUVSK5eWOYboSXr1y+Hlywwokw==", + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.8.tgz", + "integrity": "sha512-EyhO6U+QdoGYC1MeHOR0pyaaSaKYyNuT4FQNZ1eZIbnuueXpuICC7iNmLIOfr3LE5bVWcZ7NKGVPlM2StJEcgA==", "cpu": [ "arm64" ], @@ -4731,9 +5347,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.10.7", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.10.7.tgz", - "integrity": "sha512-gp5Un3EbeSThBIh6oac5ZArV/CsSmTKj5jNuuUAuEsML3VF9vqPO+25VuxCvsRf/z3py+xOWRaN2HY/rjMeZog==", + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.8.tgz", + "integrity": "sha512-QU6wOkZnS6/QuBN1MHD6G2BgFxB0AclvTVGbqYkRA7MsVkcC29PffESqzTXnypzB252/XkhQjoB2JIt9rPYf6A==", "cpu": [ "arm64" ], @@ -4748,9 +5364,9 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.10.7", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.10.7.tgz", - "integrity": "sha512-k/OxLLMl/edYqbZyUNg6/bqEHTXJT15l9WGqsl/2QaIGwWGvles8YjruQYQ9d4h/thSXLT9gd8bExU2D0N+bUA==", + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.8.tgz", + "integrity": "sha512-r72onUEIU1iJi9EUws3R28pztQ/eM3EshNpsPRBfuLwKy+qn3et55vXOyDhIjGCUph5Eg2Yn8H3h6MTxDdLd+w==", "cpu": [ "x64" ], @@ -4765,9 +5381,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.10.7", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.10.7.tgz", - "integrity": "sha512-XeDoURdWt/ybYmXLCEE8aSiTOzEn0o3Dx5l9hgt0IZEmTts7HgHHVeRgzGXbR4yDo0MfRuX5nE1dYpTmCz0uyA==", + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.8.tgz", + "integrity": "sha512-294k8cLpO103++f4ZUEDr3vnBeUfPitW6G0a3qeVZuoXFhFgaW7ANZIWknUc14WiLOMfMecphJAEiy9C8OeYSw==", "cpu": [ "x64" ], @@ -4782,9 +5398,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.10.7", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.10.7.tgz", - "integrity": "sha512-nYAbi/uLS+CU0wFtBx8TquJw2uIMKBnl04LBmiVoFrsIhqSl+0MklaA9FVMGA35NcxSJfcm92Prl2W2LfSnTqQ==", + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.8.tgz", + "integrity": "sha512-EbjOzQ+B85rumHyeesBYxZ+hq3ZQn+YAAT1ZNE9xW1/8SuLoBmHy/K9YniRGVDq/2NRmp5kI5+5h5TX0asIS9A==", "cpu": [ "arm64" ], @@ -4799,9 +5415,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.10.7", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.10.7.tgz", - "integrity": "sha512-+aGAbsDsIxeLxw0IzyQLtvtAcI1ctlXVvVcXZMNXIXtTURM876yNrufRo4ngoXB3jnb1MLjIIjgXfFs/eZTUSw==", + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.8.tgz", + "integrity": "sha512-Z+FF5kgLHfQWIZ1KPdeInToXLzbY0sMAashjd/igKeP1Lz0qKXVAK+rpn6ASJi85Fn8wTftCGCyQUkRVn0bTDg==", "cpu": [ "ia32" ], @@ -4816,9 +5432,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.10.7", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.10.7.tgz", - "integrity": "sha512-TBf4clpDBjF/UUnkKrT0/th76/zwvudk5wwobiTFqDywMApHip5O0VpBgZ+4raY2TM8k5+ujoy7bfHb22zu17Q==", + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.8.tgz", + "integrity": "sha512-j6B6N0hChCeAISS6xp/hh6zR5CSCr037BAjCxNLsT8TGe5D+gYZ57heswUWXRH8eMKiRDGiLCYpPB2pkTqxCSw==", "cpu": [ "x64" ], @@ -4848,9 +5464,9 @@ } }, "node_modules/@swc/types": { - "version": "0.1.17", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.17.tgz", - "integrity": "sha512-V5gRru+aD8YVyCOMAjMpWR1Ui577DD5KSJsHP8RAxopAH22jFz6GZd/qxqjO6MJHQhcsjvjOFXyDhyLQUnMveQ==", + "version": "0.1.19", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.19.tgz", + "integrity": "sha512-WkAZaAfj44kh/UFdAQcrMP1I0nwRqpt27u+08LMBYMqmQfwwMofYoMh/48NGkMMRfC4ynpfwRbJuu8ErfNloeA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -4858,53 +5474,63 @@ } }, "node_modules/@testcontainers/postgresql": { - "version": "10.16.0", - "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.16.0.tgz", - "integrity": "sha512-zWFQI+3QxlEELRvVv27i6zlVEPNUz9zKaSh7iWmFlCdfhcyr78daS0FG8FIfdQ79VK7YXA4jv+dTYXa2SwXu/w==", + "version": "10.18.0", + "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.18.0.tgz", + "integrity": "sha512-WxkE/tBlBpoKvqDEqL3i/mL6BOBWnXb8FXKtLhEeZ3lSt0zlldkTozMmewNsKJtFTBZdv7uFwMzWyXP12t0sxQ==", "dev": true, "license": "MIT", "dependencies": { - "testcontainers": "^10.16.0" + "testcontainers": "^10.18.0" + } + }, + "node_modules/@testcontainers/redis": { + "version": "10.18.0", + "resolved": "https://registry.npmjs.org/@testcontainers/redis/-/redis-10.18.0.tgz", + "integrity": "sha512-ZRIemaCl7C6ozC6D3PdR7BBfD3roT+EHX3ATIopUCXdemhQ/0gNaCNwt4Zq8akxkf8TvgnJkK/t6+Itm01FcVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "testcontainers": "^10.18.0" } }, "node_modules/@turf/boolean-point-in-polygon": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-7.1.0.tgz", - "integrity": "sha512-mprVsyIQ+ijWTZwbnO4Jhxu94ZW2M2CheqLiRTsGJy0Ooay9v6Av5/Nl3/Gst7ZVXxPqMeMaFYkSzcTc87AKew==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-7.2.0.tgz", + "integrity": "sha512-lvEOjxeXIp+wPXgl9kJA97dqzMfNexjqHou+XHVcfxQgolctoJiRYmcVCWGpiZ9CBf/CJha1KmD1qQoRIsjLaA==", "license": "MIT", "dependencies": { - "@turf/helpers": "^7.1.0", - "@turf/invariant": "^7.1.0", + "@turf/helpers": "^7.2.0", + "@turf/invariant": "^7.2.0", "@types/geojson": "^7946.0.10", "point-in-polygon-hao": "^1.1.0", - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "funding": { "url": "https://opencollective.com/turf" } }, "node_modules/@turf/helpers": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.1.0.tgz", - "integrity": "sha512-dTeILEUVeNbaEeoZUOhxH5auv7WWlOShbx7QSd4s0T4Z0/iz90z9yaVCtZOLbU89umKotwKaJQltBNO9CzVgaQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.2.0.tgz", + "integrity": "sha512-cXo7bKNZoa7aC7ydLmUR02oB3IgDe7MxiPuRz3cCtYQHn+BJ6h1tihmamYDWWUlPHgSNF0i3ATc4WmDECZafKw==", "license": "MIT", "dependencies": { "@types/geojson": "^7946.0.10", - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "funding": { "url": "https://opencollective.com/turf" } }, "node_modules/@turf/invariant": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-7.1.0.tgz", - "integrity": "sha512-OCLNqkItBYIP1nE9lJGuIUatWGtQ4rhBKAyTfFu0z8npVzGEYzvguEeof8/6LkKmTTEHW53tCjoEhSSzdRh08Q==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-7.2.0.tgz", + "integrity": "sha512-kV4u8e7Gkpq+kPbAKNC21CmyrXzlbBgFjO1PhrHPgEdNqXqDawoZ3i6ivE3ULJj2rSesCjduUaC/wyvH/sNr2Q==", "license": "MIT", "dependencies": { - "@turf/helpers": "^7.1.0", + "@turf/helpers": "^7.2.0", "@types/geojson": "^7946.0.10", - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "funding": { "url": "https://opencollective.com/turf" @@ -4928,9 +5554,9 @@ "license": "MIT" }, "node_modules/@types/aws-lambda": { - "version": "8.10.143", - "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.143.tgz", - "integrity": "sha512-u5vzlcR14ge/4pMTTMDQr3MF0wEe38B2F9o84uC4F43vN5DGTy63npRrB6jQhyt+C0lGv4ZfiRcRkqJoZuPnmg==", + "version": "8.10.147", + "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.147.tgz", + "integrity": "sha512-nD0Z9fNIZcxYX5Mai2CTmFD7wX7UldCkW2ezCF8D1T5hdiLsnTWDGRpfRYntU6VjTdLQjOvyszru7I1c1oCQew==", "license": "MIT" }, "node_modules/@types/bcrypt": { @@ -4955,29 +5581,23 @@ } }, "node_modules/@types/bunyan": { - "version": "1.8.9", - "resolved": "https://registry.npmjs.org/@types/bunyan/-/bunyan-1.8.9.tgz", - "integrity": "sha512-ZqS9JGpBxVOvsawzmVt30sP++gSQMTejCkIAQ3VdadOcRE8izTyW66hufvwLeH+YEGP6Js2AW7Gz+RMyvrEbmw==", + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/@types/bunyan/-/bunyan-1.8.11.tgz", + "integrity": "sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ==", "license": "MIT", "dependencies": { "@types/node": "*" } }, "node_modules/@types/connect": { - "version": "3.4.36", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.36.tgz", - "integrity": "sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==", + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "license": "MIT", "dependencies": { "@types/node": "*" } }, - "node_modules/@types/cookie": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", - "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", - "license": "MIT" - }, "node_modules/@types/cookie-parser": { "version": "1.4.8", "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.8.tgz", @@ -5016,9 +5636,9 @@ } }, "node_modules/@types/dockerode": { - "version": "3.3.32", - "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.32.tgz", - "integrity": "sha512-xxcG0g5AWKtNyh7I7wswLdFvym4Mlqks5ZlKzxEUrGHS0r0PUOfxm2T0mspwu10mHQqu3Ck3MI3V2HqvLWE1fg==", + "version": "3.3.35", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.35.tgz", + "integrity": "sha512-P+DCMASlsH+QaKkDpekKrP5pLls767PPs+/LrlVbKnEnY5tMpEUa2C6U4gRsdFZengOqxdCIqy16R22Q3pLB6Q==", "dev": true, "license": "MIT", "dependencies": { @@ -5093,9 +5713,9 @@ } }, "node_modules/@types/geojson": { - "version": "7946.0.15", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.15.tgz", - "integrity": "sha512-9oSxFzDCT2Rj6DfcHF8G++jxBKS7mBqXl5xrRW+Kbvjry6Uduya2iiwqHPhVXpasAVMBYKkEPGgKhd3+/HZ6xA==", + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", "license": "MIT" }, "node_modules/@types/http-errors": { @@ -5131,9 +5751,9 @@ "license": "MIT" }, "node_modules/@types/lodash": { - "version": "4.17.14", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.14.tgz", - "integrity": "sha512-jsxagdikDiDBeIRaPYtArcT8my4tN1og7MtMRquFT3XNA6axxyHDRUemqDz/taRDdOUn0GnGHRCuff4q48sW9A==", + "version": "4.17.16", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz", + "integrity": "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==", "dev": true, "license": "MIT" }, @@ -5196,14 +5816,24 @@ } }, "node_modules/@types/node": { - "version": "22.10.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.9.tgz", - "integrity": "sha512-Ir6hwgsKyNESl/gLOcEz3krR4CBGgliDqBQ2ma4wIhEx0w+xnoeTq3tdrNw15kU3SxogDjOgv9sqdtLW8mIHaw==", + "version": "22.13.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", + "integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==", "license": "MIT", "dependencies": { "undici-types": "~6.20.0" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, "node_modules/@types/nodemailer": { "version": "6.4.17", "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz", @@ -5259,9 +5889,9 @@ } }, "node_modules/@types/qs": { - "version": "6.9.17", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.17.tgz", - "integrity": "sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ==", + "version": "6.9.18", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", + "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==", "dev": true, "license": "MIT" }, @@ -5273,9 +5903,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.0.7", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.7.tgz", - "integrity": "sha512-MoFsEJKkAtZCrC1r6CM8U22GzhG7u2Wir8ons/aCKH6MBdD1ibV24zOSSkdZVUKqN5i396zG5VKLYZ3yaUZdLA==", + "version": "19.0.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz", + "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", "dev": true, "license": "MIT", "dependencies": { @@ -5292,6 +5922,16 @@ "@types/node": "*" } }, + "node_modules/@types/sanitize-html": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.13.0.tgz", + "integrity": "sha512-X31WxbvW9TjIhZZNyNBZ/p5ax4ti7qsNDBDEnH4zAgmEh35YnFD1UiS6z9Cd34kKm0LslFW0KPmTQzu/oGtsqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "htmlparser2": "^8.0.0" + } + }, "node_modules/@types/semver": { "version": "7.5.8", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", @@ -5329,9 +5969,9 @@ "license": "MIT" }, "node_modules/@types/ssh2": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.1.tgz", - "integrity": "sha512-ZIbEqKAsi5gj35y4P4vkJYly642wIbY6PqoN0xiyQGshKUGXR9WQjF/iF9mXBQ8uBKy3ezfsCkcoHKhd0BzuDA==", + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.4.tgz", + "integrity": "sha512-9JTQgVBWSgq6mAen6PVnrAmty1lqgCMvpfN+1Ck5WRUsyMYPa6qd50/vMJ0y1zkGpOEgLzm8m8Dx/Y5vRouLaA==", "dev": true, "license": "MIT", "dependencies": { @@ -5349,9 +5989,9 @@ } }, "node_modules/@types/ssh2/node_modules/@types/node": { - "version": "18.19.68", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.68.tgz", - "integrity": "sha512-QGtpFH1vB99ZmTa63K4/FU8twThj4fuVSBkGddTp7uIL/cuoLWIUSL2RcOaigBhfR+hg5pgGkBnkoOxrTVBMKw==", + "version": "18.19.79", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.79.tgz", + "integrity": "sha512-90K8Oayimbctc5zTPHPfZloc/lGVs7f3phUAAMcTgEPtg8kKquGZDERC8K4vkBYkQQh48msiYUslYtxTWvqcAg==", "dev": true, "license": "MIT", "dependencies": { @@ -5422,21 +6062,21 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.20.0.tgz", - "integrity": "sha512-naduuphVw5StFfqp4Gq4WhIBE2gN1GEmMUExpJYknZJdRnc+2gDzB8Z3+5+/Kv33hPQRDGzQO/0opHE72lZZ6A==", + "version": "8.26.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.26.0.tgz", + "integrity": "sha512-cLr1J6pe56zjKYajK6SSSre6nl1Gj6xDp1TY0trpgPzjVbgDwd09v2Ws37LABxzkicmUjhEeg/fAUjPJJB1v5Q==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/type-utils": "8.20.0", - "@typescript-eslint/utils": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", + "@typescript-eslint/scope-manager": "8.26.0", + "@typescript-eslint/type-utils": "8.26.0", + "@typescript-eslint/utils": "8.26.0", + "@typescript-eslint/visitor-keys": "8.26.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5448,20 +6088,20 @@ "peerDependencies": { "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.20.0.tgz", - "integrity": "sha512-gKXG7A5HMyjDIedBi6bUrDcun8GIjnI8qOwVLiY3rx6T/sHP/19XLJOnIq/FgQvWLHja5JN/LSE7eklNBr612g==", + "version": "8.26.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.26.0.tgz", + "integrity": "sha512-mNtXP9LTVBy14ZF3o7JG69gRPBK/2QWtQd0j0oH26HcY/foyJJau6pNUez7QrM5UHnSvwlQcJXKsk0I99B9pOA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/typescript-estree": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", + "@typescript-eslint/scope-manager": "8.26.0", + "@typescript-eslint/types": "8.26.0", + "@typescript-eslint/typescript-estree": "8.26.0", + "@typescript-eslint/visitor-keys": "8.26.0", "debug": "^4.3.4" }, "engines": { @@ -5473,18 +6113,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.20.0.tgz", - "integrity": "sha512-J7+VkpeGzhOt3FeG1+SzhiMj9NzGD/M6KoGn9f4dbz3YzK9hvbhVTmLj/HiTp9DazIzJ8B4XcM80LrR9Dm1rJw==", + "version": "8.26.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.26.0.tgz", + "integrity": "sha512-E0ntLvsfPqnPwng8b8y4OGuzh/iIOm2z8U3S9zic2TeMLW61u5IH2Q1wu0oSTkfrSzwbDJIB/Lm8O3//8BWMPA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0" + "@typescript-eslint/types": "8.26.0", + "@typescript-eslint/visitor-keys": "8.26.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5495,16 +6135,16 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.20.0.tgz", - "integrity": "sha512-bPC+j71GGvA7rVNAHAtOjbVXbLN5PkwqMvy1cwGeaxUoRQXVuKCebRoLzm+IPW/NtFFpstn1ummSIasD5t60GA==", + "version": "8.26.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.26.0.tgz", + "integrity": "sha512-ruk0RNChLKz3zKGn2LwXuVoeBcUMh+jaqzN461uMMdxy5H9epZqIBtYj7UiPXRuOpaALXGbmRuZQhmwHhaS04Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.20.0", - "@typescript-eslint/utils": "8.20.0", + "@typescript-eslint/typescript-estree": "8.26.0", + "@typescript-eslint/utils": "8.26.0", "debug": "^4.3.4", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5515,13 +6155,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.20.0.tgz", - "integrity": "sha512-cqaMiY72CkP+2xZRrFt3ExRBu0WmVitN/rYPZErA80mHjHx/Svgp8yfbzkJmDoQ/whcytOPO9/IZXnOc+wigRA==", + "version": "8.26.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.26.0.tgz", + "integrity": "sha512-89B1eP3tnpr9A8L6PZlSjBvnJhWXtYfZhECqlBl1D9Lme9mHO6iWlsprBtVenQvY1HMhax1mWOjhtL3fh/u+pA==", "dev": true, "license": "MIT", "engines": { @@ -5533,20 +6173,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.20.0.tgz", - "integrity": "sha512-Y7ncuy78bJqHI35NwzWol8E0X7XkRVS4K4P4TCyzWkOJih5NDvtoRDW4Ba9YJJoB2igm9yXDdYI/+fkiiAxPzA==", + "version": "8.26.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.26.0.tgz", + "integrity": "sha512-tiJ1Hvy/V/oMVRTbEOIeemA2XoylimlDQ03CgPPNaHYZbpsc78Hmngnt+WXZfJX1pjQ711V7g0H7cSJThGYfPQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", + "@typescript-eslint/types": "8.26.0", + "@typescript-eslint/visitor-keys": "8.26.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5556,7 +6196,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { @@ -5586,16 +6226,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.20.0.tgz", - "integrity": "sha512-dq70RUw6UK9ei7vxc4KQtBRk7qkHZv447OUZ6RPQMQl71I3NZxQJX/f32Smr+iqWrB02pHKn2yAdHBb0KNrRMA==", + "version": "8.26.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.26.0.tgz", + "integrity": "sha512-2L2tU3FVwhvU14LndnQCA2frYC8JnPDVKyQtWFPf8IYFMt/ykEN1bPolNhNbCVgOmdzTlWdusCTKA/9nKrf8Ig==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/typescript-estree": "8.20.0" + "@typescript-eslint/scope-manager": "8.26.0", + "@typescript-eslint/types": "8.26.0", + "@typescript-eslint/typescript-estree": "8.26.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5606,17 +6246,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.20.0.tgz", - "integrity": "sha512-v/BpkeeYAsPkKCkR8BDwcno0llhzWVqPOamQrAEMdpZav2Y9OVjd9dwJyBLJWwf335B5DmlifECIkZRJCaGaHA==", + "version": "8.26.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.26.0.tgz", + "integrity": "sha512-2z8JQJWAzPdDd51dRQ/oqIJxe99/hoLIqmf8RMCAJQtYDc535W/Jt2+RTP4bP0aKeBG1F65yjIZuczOXCmbWwg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.20.0", + "@typescript-eslint/types": "8.26.0", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -5641,9 +6281,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.3.tgz", - "integrity": "sha512-uVbJ/xhImdNtzPnLyxCZJMTeTIYdgcC2nWtBBBpR1H6z0w8m7D+9/zrDIx2nNxgMg9r+X8+RY2qVpUDeW2b3nw==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.8.tgz", + "integrity": "sha512-y7SAKsQirsEJ2F8bulBck4DoluhI2EEgTimHd6EEUgJBGKy9tC25cpywh1MH4FvDGoG2Unt7+asVd1kj4qOSAw==", "dev": true, "license": "MIT", "dependencies": { @@ -5664,8 +6304,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.0.3", - "vitest": "3.0.3" + "@vitest/browser": "3.0.8", + "vitest": "3.0.8" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -5673,26 +6313,16 @@ } } }, - "node_modules/@vitest/coverage-v8/node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" - } - }, "node_modules/@vitest/expect": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.3.tgz", - "integrity": "sha512-SbRCHU4qr91xguu+dH3RUdI5dC86zm8aZWydbp961aIR7G8OYNN6ZiayFuf9WAngRbFOfdrLHCGgXTj3GtoMRQ==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.8.tgz", + "integrity": "sha512-Xu6TTIavTvSSS6LZaA3EebWFr6tsoXPetOWNMOlc7LO88QVVBwq2oQWBoDiLCN6YTvNYsGSjqOO8CAdjom5DCQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.0.3", - "@vitest/utils": "3.0.3", - "chai": "^5.1.2", + "@vitest/spy": "3.0.8", + "@vitest/utils": "3.0.8", + "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, "funding": { @@ -5700,13 +6330,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.3.tgz", - "integrity": "sha512-XT2XBc4AN9UdaxJAeIlcSZ0ILi/GzmG5G8XSly4gaiqIvPV3HMTSIDZWJVX6QRJ0PX1m+W8Cy0K9ByXNb/bPIA==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.8.tgz", + "integrity": "sha512-n3LjS7fcW1BCoF+zWZxG7/5XvuYH+lsFg+BDwwAz0arIwHQJFUEsKBQ0BLU49fCxuM/2HSeBPHQD8WjgrxMfow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.0.3", + "@vitest/spy": "3.0.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -5736,20 +6366,10 @@ "@types/estree": "^1.0.0" } }, - "node_modules/@vitest/mocker/node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" - } - }, "node_modules/@vitest/pretty-format": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.3.tgz", - "integrity": "sha512-gCrM9F7STYdsDoNjGgYXKPq4SkSxwwIU5nkaQvdUxiQ0EcNlez+PdKOVIsUJvh9P9IeIFmjn4IIREWblOBpP2Q==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.8.tgz", + "integrity": "sha512-BNqwbEyitFhzYMYHUVbIvepOyeQOSFA/NeJMIP9enMntkkxLgOcgABH6fjyXG85ipTgvero6noreavGIqfJcIg==", "dev": true, "license": "MIT", "dependencies": { @@ -5760,48 +6380,38 @@ } }, "node_modules/@vitest/runner": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.3.tgz", - "integrity": "sha512-Rgi2kOAk5ZxWZlwPguRJFOBmWs6uvvyAAR9k3MvjRvYrG7xYvKChZcmnnpJCS98311CBDMqsW9MzzRFsj2gX3g==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.8.tgz", + "integrity": "sha512-c7UUw6gEcOzI8fih+uaAXS5DwjlBaCJUo7KJ4VvJcjL95+DSR1kova2hFuRt3w41KZEFcOEiq098KkyrjXeM5w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.0.3", - "pathe": "^2.0.1" + "@vitest/utils": "3.0.8", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.3.tgz", - "integrity": "sha512-kNRcHlI4txBGztuJfPEJ68VezlPAXLRT1u5UCx219TU3kOG2DplNxhWLwDf2h6emwmTPogzLnGVwP6epDaJN6Q==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.8.tgz", + "integrity": "sha512-x8IlMGSEMugakInj44nUrLSILh/zy1f2/BgH0UeHpNyOocG18M9CWVIFBaXPt8TrqVZWmcPjwfG/ht5tnpba8A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.0.3", + "@vitest/pretty-format": "3.0.8", "magic-string": "^0.30.17", - "pathe": "^2.0.1" + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot/node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" - } - }, "node_modules/@vitest/spy": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.3.tgz", - "integrity": "sha512-7/dgux8ZBbF7lEIKNnEqQlyRaER9nkAL9eTmdKJkDO3hS8p59ATGwKOCUDHcBLKr7h/oi/6hP+7djQk8049T2A==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.8.tgz", + "integrity": "sha512-MR+PzJa+22vFKYb934CejhR4BeRpMSoxkvNoDit68GQxRLSf11aT6CTj3XaqUU9rxgWJFnqicN/wxw6yBRkI1Q==", "dev": true, "license": "MIT", "dependencies": { @@ -5812,14 +6422,14 @@ } }, "node_modules/@vitest/utils": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.3.tgz", - "integrity": "sha512-f+s8CvyzPtMFY1eZKkIHGhPsQgYo5qCm6O8KZoim9qm1/jT64qBgGpO5tHscNH6BzRHM+edLNOP+3vO8+8pE/A==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.8.tgz", + "integrity": "sha512-nkBC3aEhfX2PdtQI/QwAWp8qZWwzASsU4Npbcd5RdMPBSSLCpkZp52P3xku3s3uA0HIEhGvEcF8rNkBsz9dQ4Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.0.3", - "loupe": "^3.1.2", + "@vitest/pretty-format": "3.0.8", + "loupe": "^3.1.3", "tinyrainbow": "^2.0.0" }, "funding": { @@ -6020,22 +6630,22 @@ } }, "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" }, "engines": { "node": ">= 0.6" } }, "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -6093,9 +6703,9 @@ } }, "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6170,12 +6780,15 @@ } }, "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, "node_modules/ansi-styles": { @@ -6193,11 +6806,21 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ansis": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-3.16.0.tgz", + "integrity": "sha512-sU7d/tfZiYrsIAXbdL/CNZld5bCkruzwT5KmqmadCJYxuLxHAOBjidxD5+iLmN/6xEfjcQq1l7OpsiCBlc4LzA==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/anymatch": { "version": "3.1.3", @@ -6281,6 +6904,87 @@ "node": ">= 14" } }, + "node_modules/archiver-utils/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/archiver-utils/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/archiver-utils/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/are-we-there-yet": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", @@ -6322,12 +7026,6 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, "node_modules/array-source": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/array-source/-/array-source-0.0.4.tgz", @@ -6377,7 +7075,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/b4a": { @@ -6393,53 +7090,71 @@ "license": "MIT" }, "node_modules/bare-events": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.0.tgz", - "integrity": "sha512-/E8dDe9dsbLyh2qrZ64PEPadOQ0F4gbl1sUJOrmph7xOiIxfY8vwab/4bFLh4Y88/Hk/ujKcrQKc+ps0mv873A==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", + "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", "license": "Apache-2.0", "optional": true }, "node_modules/bare-fs": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.3.5.tgz", - "integrity": "sha512-SlE9eTxifPDJrT6YgemQ1WGFleevzwY+XAP1Xqgl56HtcrisC2CHCZ2tq6dBpcH2TnNxwUEUGhweo+lrQtYuiw==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.0.1.tgz", + "integrity": "sha512-ilQs4fm/l9eMfWY2dY0WCIUplSUp7U0CT1vrqMg1MUdeZl4fypu5UP0XcDBK5WBQPJAKP1b7XEodISmekH/CEg==", "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { "bare-events": "^2.0.0", - "bare-path": "^2.0.0", + "bare-path": "^3.0.0", "bare-stream": "^2.0.0" + }, + "engines": { + "bare": ">=1.7.0" } }, "node_modules/bare-os": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.4.4.tgz", - "integrity": "sha512-z3UiI2yi1mK0sXeRdc4O1Kk8aOa/e+FNWZcTiPB/dfTWyLypuE99LibgRaQki914Jq//yAWylcAt+mknKdixRQ==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.5.1.tgz", + "integrity": "sha512-LvfVNDcWLw2AnIw5f2mWUgumW3I3N/WYGiWeimhQC1Ybt71n2FjlS9GJKeCnFeg1MKZHxzIFmpFnBXDI+sBeFg==", "dev": true, "license": "Apache-2.0", - "optional": true + "optional": true, + "engines": { + "bare": ">=1.14.0" + } }, "node_modules/bare-path": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-2.1.3.tgz", - "integrity": "sha512-lh/eITfU8hrj9Ru5quUp0Io1kJWIk1bTjzo7JH1P5dWmQ2EL4hFUlfI8FonAhSlgIfhn63p84CDY/x+PisgcXA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { - "bare-os": "^2.1.0" + "bare-os": "^3.0.1" } }, "node_modules/bare-stream": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.1.tgz", - "integrity": "sha512-eVZbtKM+4uehzrsj49KtCy3Pbg7kO1pJ3SKZ1SFrIH/0pnj9scuGGgUlNDf/7qS8WKtGdiJY5Kyhs/ivYPTB/g==", + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz", + "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==", "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } } }, "node_modules/base64-js": { @@ -6557,44 +7272,40 @@ } }, "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.1.0.tgz", + "integrity": "sha512-/hPxh61E+ll0Ujp24Ilm64cykicul1ypfwjVttduAiEdtnJFvLePSrIPk+HMImtNv5270wOGCb1Tns2rybMkoQ==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.5.2", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": ">=18" } }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", + "node_modules/body-parser/node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", "dependencies": { - "ms": "2.0.0" + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -6618,9 +7329,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.3.tgz", - "integrity": "sha512-1CPmv8iobE2fyRMV97dAcMVegvvWKxmq94hkLiAkUGwKVTyDLw33K+ZxiFrREKmmps4rIw6grcCFCnTMSZ/YiA==", + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", + "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", "funding": [ { "type": "opencollective", @@ -6769,19 +7480,6 @@ "node": ">=10" } }, - "node_modules/bullmq/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -6822,29 +7520,10 @@ "node": ">=8" } }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/call-bind-apply-helpers": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", - "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6855,13 +7534,13 @@ } }, "node_modules/call-bound": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", - "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "get-intrinsic": "^1.2.6" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { "node": ">= 0.4" @@ -6890,9 +7569,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001689", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001689.tgz", - "integrity": "sha512-CmeR2VBycfa+5/jOfnp/NpWPGd06nf1XYiefUvhXFfZE4GkRc9jv+eGPS4nT558WS/8lYCzV8SlANCIPvbWP1g==", + "version": "1.0.30001702", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001702.tgz", + "integrity": "sha512-LoPe/D7zioC0REI5W73PeR1e1MLCipRGq/VkovJnd6Df+QVqT+vT33OXCp8QUd7kA7RZrHWxb1B36OQKI/0gOA==", "funding": [ { "type": "opencollective", @@ -6910,9 +7589,9 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz", - "integrity": "sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", + "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", "dev": true, "license": "MIT", "dependencies": { @@ -7018,9 +7697,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz", - "integrity": "sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", "license": "MIT" }, "node_modules/class-transformer": { @@ -7075,82 +7754,6 @@ "node": ">=8" } }, - "node_modules/cli-highlight": { - "version": "2.1.11", - "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", - "integrity": "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==", - "license": "ISC", - "dependencies": { - "chalk": "^4.0.0", - "highlight.js": "^10.7.1", - "mz": "^2.4.0", - "parse5": "^5.1.1", - "parse5-htmlparser2-tree-adapter": "^6.0.0", - "yargs": "^16.0.0" - }, - "bin": { - "highlight": "bin/highlight" - }, - "engines": { - "node": ">=8.0.0", - "npm": ">=5.0.0" - } - }, - "node_modules/cli-highlight/node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/cli-highlight/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/cli-highlight/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "license": "MIT", - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/cli-highlight/node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/cli-spinners": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", @@ -7180,12 +7783,13 @@ } }, "node_modules/cli-width": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", - "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, "license": "ISC", "engines": { - "node": ">= 10" + "node": ">= 12" } }, "node_modules/client-only": { @@ -7208,6 +7812,27 @@ "node": ">=12" } }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/cliui/node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -7297,7 +7922,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -7384,10 +8008,13 @@ } }, "node_modules/consola": { - "version": "2.15.3", - "resolved": "https://registry.npmjs.org/consola/-/consola-2.15.3.tgz", - "integrity": "sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==", - "license": "MIT" + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.0.tgz", + "integrity": "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } }, "node_modules/console-control-strings": { "version": "1.1.0", @@ -7396,9 +8023,9 @@ "license": "ISC" }, "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", "license": "MIT", "dependencies": { "safe-buffer": "5.2.1" @@ -7423,12 +8050,12 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=18" } }, "node_modules/cookie-parser": { @@ -7444,6 +8071,15 @@ "node": ">= 0.8.0" } }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -7451,13 +8087,13 @@ "license": "MIT" }, "node_modules/core-js-compat": { - "version": "3.39.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.39.0.tgz", - "integrity": "sha512-VgEUx3VwlExr5no0tXlBt+silBvhTryPwCXRI2Id1PN8WTKu7MreethvddqOubrYxkFdv/RnYrqlv1sFNAUelw==", + "version": "3.41.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.41.0.tgz", + "integrity": "sha512-RFsU9LySVue9RTwdDVX/T0e2Y6jRYWXERKElIjpuEOEnxaXffI0X7RUwVzfYLfzuLXSNJDYoRYUAmRUcyln20A==", "dev": true, "license": "MIT", "dependencies": { - "browserslist": "^4.24.2" + "browserslist": "^4.24.4" }, "funding": { "type": "opencollective", @@ -7678,29 +8314,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -7740,6 +8357,26 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-europe-js": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/detect-europe-js/-/detect-europe-js-0.1.2.tgz", + "integrity": "sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", @@ -7762,6 +8399,16 @@ "license": "Apache-2.0", "peer": true }, + "node_modules/diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/discontinuous-range": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", @@ -7929,9 +8576,9 @@ } }, "node_modules/domutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", - "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^2.0.0", @@ -7955,9 +8602,9 @@ } }, "node_modules/dotenv-expand": { - "version": "11.0.7", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", - "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.1.tgz", + "integrity": "sha512-LaKRbou8gt0RNID/9RoI+J2rvXsBRPMV7p+ElHlPhcSARbCPDYcYG2s1TIzAfWv4YSgyY5taidWzzs31lNV3yQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -7997,9 +8644,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.74", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.74.tgz", - "integrity": "sha512-ck3//9RC+6oss/1Bh9tiAVFy5vfSKbRHAFh7Z3/eTRkEqJeWgymloShB17Vg3Z4nmDNp35vAd1BZ6CMW4Wt6Iw==", + "version": "1.5.113", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.113.tgz", + "integrity": "sha512-wjT2O4hX+wdWPJ76gWSkMhcHAV2PTMX+QetUCPYEdCIe+cxmgzzSSiGRCKW8nuh4mwKZlpv0xvoW7OF2X+wmHg==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -8028,12 +8675,11 @@ } }, "node_modules/engine.io": { - "version": "6.6.2", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.2.tgz", - "integrity": "sha512-gmNvsYi9C8iErnZdVcJnvCpSKbWTt1E8+JZo8b+daLninywUWi5NQ5STSHZ9rFjFO7imNcvb8Pc5pe/wMR5xEw==", + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", + "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==", "license": "MIT", "dependencies": { - "@types/cookie": "^0.4.1", "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "accepts": "~1.3.4", @@ -8057,6 +8703,28 @@ "node": ">=10.0.0" } }, + "node_modules/engine.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/engine.io/node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", @@ -8074,10 +8742,40 @@ } } }, + "node_modules/engine.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/enhanced-resolve": { - "version": "5.17.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", - "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", + "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", "dev": true, "license": "MIT", "dependencies": { @@ -8100,6 +8798,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -8135,9 +8843,9 @@ "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", - "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -8146,42 +8854,58 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.11.tgz", - "integrity": "sha512-HJ96Hev2hX/6i5cDVwcqiJBBtuo9+FeIJOtZ9W1kA5M6AMJRHUZlpYZ1/SbEwtO0ioNAW8rUooVpC/WehY2SfA==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.0.tgz", + "integrity": "sha512-1lvV17H2bMYda/WaFb2jLPeHU3zml2k4/yagNMG8Q/YtfMjCwEUZa2eXXMgZTVSL5q1n4H7sQ0X6CdJDqqeCFA==", "hasInstallScript": true, "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.19.11", - "@esbuild/android-arm": "0.19.11", - "@esbuild/android-arm64": "0.19.11", - "@esbuild/android-x64": "0.19.11", - "@esbuild/darwin-arm64": "0.19.11", - "@esbuild/darwin-x64": "0.19.11", - "@esbuild/freebsd-arm64": "0.19.11", - "@esbuild/freebsd-x64": "0.19.11", - "@esbuild/linux-arm": "0.19.11", - "@esbuild/linux-arm64": "0.19.11", - "@esbuild/linux-ia32": "0.19.11", - "@esbuild/linux-loong64": "0.19.11", - "@esbuild/linux-mips64el": "0.19.11", - "@esbuild/linux-ppc64": "0.19.11", - "@esbuild/linux-riscv64": "0.19.11", - "@esbuild/linux-s390x": "0.19.11", - "@esbuild/linux-x64": "0.19.11", - "@esbuild/netbsd-x64": "0.19.11", - "@esbuild/openbsd-x64": "0.19.11", - "@esbuild/sunos-x64": "0.19.11", - "@esbuild/win32-arm64": "0.19.11", - "@esbuild/win32-ia32": "0.19.11", - "@esbuild/win32-x64": "0.19.11" + "@esbuild/aix-ppc64": "0.23.0", + "@esbuild/android-arm": "0.23.0", + "@esbuild/android-arm64": "0.23.0", + "@esbuild/android-x64": "0.23.0", + "@esbuild/darwin-arm64": "0.23.0", + "@esbuild/darwin-x64": "0.23.0", + "@esbuild/freebsd-arm64": "0.23.0", + "@esbuild/freebsd-x64": "0.23.0", + "@esbuild/linux-arm": "0.23.0", + "@esbuild/linux-arm64": "0.23.0", + "@esbuild/linux-ia32": "0.23.0", + "@esbuild/linux-loong64": "0.23.0", + "@esbuild/linux-mips64el": "0.23.0", + "@esbuild/linux-ppc64": "0.23.0", + "@esbuild/linux-riscv64": "0.23.0", + "@esbuild/linux-s390x": "0.23.0", + "@esbuild/linux-x64": "0.23.0", + "@esbuild/netbsd-x64": "0.23.0", + "@esbuild/openbsd-arm64": "0.23.0", + "@esbuild/openbsd-x64": "0.23.0", + "@esbuild/sunos-x64": "0.23.0", + "@esbuild/win32-arm64": "0.23.0", + "@esbuild/win32-ia32": "0.23.0", + "@esbuild/win32-x64": "0.23.0" } }, "node_modules/escalade": { @@ -8203,7 +8927,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -8213,22 +8936,22 @@ } }, "node_modules/eslint": { - "version": "9.18.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.18.0.tgz", - "integrity": "sha512-+waTfRWQlSbpt3KWE+CjrPPYnbq9kfZIYUqapc0uBXyjTp8aYXZDsUH16m39Ryq3NjAVP4tjuF7KaukeqoCoaA==", + "version": "9.21.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.21.0.tgz", + "integrity": "sha512-KjeihdFqTPhOMXTt7StsDxriV4n66ueuF/jfPNC3j/lduHwr/ijDwJMsF+wyMJethgiKi5wniIE243vi07d3pg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.19.0", - "@eslint/core": "^0.10.0", - "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "9.18.0", - "@eslint/plugin-kit": "^0.2.5", + "@eslint/config-array": "^0.19.2", + "@eslint/core": "^0.12.0", + "@eslint/eslintrc": "^3.3.0", + "@eslint/js": "9.21.0", + "@eslint/plugin-kit": "^0.2.7", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.1", + "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", @@ -8273,22 +8996,22 @@ } }, "node_modules/eslint-config-prettier": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.0.1.tgz", - "integrity": "sha512-lZBts941cyJyeaooiKxAtzoPHTN+GbQTJFAIdQbRhA4/8whaAraEh47Whw/ZFfrjNSnlAxqfm9i0XVAEkULjCw==", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.1.tgz", + "integrity": "sha512-4EQQr6wXwS+ZJSzaR5ZCrYgLxqvUjdXctaEtBqHcbkW944B1NQyO4qpdHQbXBONfwxXdkAY81HH4+LUfrg+zPw==", "dev": true, "license": "MIT", "bin": { - "eslint-config-prettier": "build/bin/cli.js" + "eslint-config-prettier": "bin/cli.js" }, "peerDependencies": { "eslint": ">=7.0.0" } }, "node_modules/eslint-plugin-prettier": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.2.tgz", - "integrity": "sha512-1yI3/hf35wmlq66C8yOyrujQnel+v5l1Vop5Cl2I6ylyNTT1JbuUUnV3/41PzwTzcyDp/oF0jWE3HXvcH5AQOQ==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.3.tgz", + "integrity": "sha512-qJ+y0FfCp/mQYQ/vWQ3s7eUlFEL4PyKfAJxsnYTJ4YT73nsJBWqmEpFryxV9OeUiqmsTsYJ5Y+KDNaeP31wrRw==", "dev": true, "license": "MIT", "dependencies": { @@ -8350,6 +9073,19 @@ "eslint": ">=8.56.0" } }, + "node_modules/eslint-plugin-unicorn/node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint-scope": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz", @@ -8575,9 +9311,9 @@ ] }, "node_modules/expect-type": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.1.0.tgz", - "integrity": "sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.0.tgz", + "integrity": "sha512-80F22aiJ3GLyVnS/B3HzgR6RelZVumzj9jkL0Rhz4h0xYbNW9PjlQz5h3J/SShErbXBc295vseR4/MIbVmUbeA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -8585,49 +9321,46 @@ } }, "node_modules/express": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", - "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.0.1.tgz", + "integrity": "sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==", "license": "MIT", "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", + "accepts": "^2.0.0", + "body-parser": "^2.0.1", + "content-disposition": "^1.0.0", "content-type": "~1.0.4", "cookie": "0.7.1", - "cookie-signature": "1.0.6", - "debug": "2.6.9", + "cookie-signature": "^1.2.1", + "debug": "4.3.6", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", + "finalhandler": "^2.0.0", + "fresh": "2.0.0", "http-errors": "2.0.0", - "merge-descriptors": "1.0.3", + "merge-descriptors": "^2.0.0", "methods": "~1.1.2", + "mime-types": "^3.0.0", "on-finished": "2.4.1", + "once": "1.4.0", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", + "router": "^2.0.0", "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", + "send": "^1.1.0", + "serve-static": "^2.1.0", "setprototypeof": "1.2.0", "statuses": "2.0.1", - "type-is": "~1.6.18", + "type-is": "^2.0.0", "utils-merge": "1.0.1", "vary": "~1.1.2" }, "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 18" } }, "node_modules/express/node_modules/cookie": { @@ -8639,25 +9372,36 @@ "node": ">= 0.6" } }, + "node_modules/express/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", "license": "MIT", "dependencies": { - "ms": "2.0.0" + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/express/node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "license": "MIT" }, "node_modules/extend": { @@ -8680,6 +9424,18 @@ "node": ">=4" } }, + "node_modules/external-editor/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -8737,16 +9493,26 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", - "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", + "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "BSD-3-Clause" }, "node_modules/fastq": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", - "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -8811,38 +9577,22 @@ } }, "node_modules/finalhandler": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" }, "engines": { "node": ">= 0.8" } }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -8875,9 +9625,9 @@ } }, "node_modules/flatted": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", - "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, "license": "ISC" }, @@ -8912,12 +9662,12 @@ } }, "node_modules/foreground-child": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", - "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "license": "ISC", "dependencies": { - "cross-spawn": "^7.0.0", + "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" }, "engines": { @@ -8957,20 +9707,41 @@ } }, "node_modules/form-data": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", - "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", - "dev": true, + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", "mime-types": "^2.1.12" }, "engines": { "node": ">= 6" } }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -8987,12 +9758,12 @@ "license": "MIT" }, "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/fs-constants": { @@ -9098,12 +9869,33 @@ "node": ">=10" } }, + "node_modules/gauge/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/gauge/node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/gauge/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/gaxios": { "version": "6.7.1", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", @@ -9142,26 +9934,14 @@ "node": ">= 14" } }, - "node_modules/gaxios/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/gcp-metadata": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.0.tgz", - "integrity": "sha512-Jh/AIwwgaxan+7ZUUmRLCjtchyDiqh4KjBJ5tW3plBZb5iL/BPcso8A5DlzeD9qlw0duCamnNdpFjxwaT0KyKg==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", "license": "Apache-2.0", "dependencies": { - "gaxios": "^6.0.0", + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", "json-bigint": "^1.0.0" }, "engines": { @@ -9178,9 +9958,9 @@ } }, "node_modules/geo-tz": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/geo-tz/-/geo-tz-8.1.2.tgz", - "integrity": "sha512-S1udoP7MZ+CVu+7Iy/VayVNmEHTWgfJ52TjpfC2/4f+j0SB/ZXMjGrwZTqPMo6/O2m5lrGLCFCY0bkxUqiLN+g==", + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/geo-tz/-/geo-tz-8.1.3.tgz", + "integrity": "sha512-zzF0hjqLl+1n5tXDCxwdS/BmF+N1TdQc6rbubh6PO6/9DtntX/yBox1Ti0q24MrjajWG0fSv0gv2w6Zff/kmeA==", "license": "MIT", "dependencies": { "@turf/boolean-point-in-polygon": "^7.1.0", @@ -9221,21 +10001,21 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz", - "integrity": "sha512-qxsEs+9A+u85HhllWJJFicJfPDhRmjzoYdl64aMWW9yRIJmSyxdn8IEkuIM530/7T+lv0TIHd8L6Q/ra0tEoeA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "dunder-proto": "^1.0.0", + "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", + "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", - "math-intrinsics": "^1.0.0" + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -9257,6 +10037,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stdin": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz", @@ -9275,6 +10068,7 @@ "resolved": "https://registry.npmjs.org/git-diff/-/git-diff-2.0.6.tgz", "integrity": "sha512-/Iu4prUrydE3Pb3lCBMbcSNIf81tgGt0W1ZwknnyF62t3tHmtiJTRj0f+1ZIhp3+Rh0ktz1pJVoa7ZXUCskivA==", "dev": true, + "license": "ISC", "dependencies": { "chalk": "^2.3.2", "diff": "^3.5.0", @@ -9291,6 +10085,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^1.9.0" }, @@ -9303,6 +10098,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -9317,6 +10113,7 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "1.1.3" } @@ -9325,22 +10122,15 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "node_modules/git-diff/node_modules/diff": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", - "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", "dev": true, - "engines": { - "node": ">=0.3.1" - } + "license": "MIT" }, "node_modules/git-diff/node_modules/escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8.0" } @@ -9350,6 +10140,7 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -9359,6 +10150,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^3.0.0" }, @@ -9367,21 +10159,25 @@ } }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.1.tgz", + "integrity": "sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw==", + "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", + "jackspeak": "^4.0.1", + "minimatch": "^10.0.0", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" }, + "engines": { + "node": "20 || >=22" + }, "funding": { "url": "https://github.com/sponsors/isaacs" } @@ -9409,30 +10205,32 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, "node_modules/glob/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", + "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/globals": { - "version": "15.14.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.14.0.tgz", - "integrity": "sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==", + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.0.0.tgz", + "integrity": "sha512-iInW14XItCXET01CQFqudPOWP2jYMl7T+QRQT+UNcR/iQncN/F0UNpgd76iFkBPgNQb4+X3LV9tLJYzwh+Gl3A==", "dev": true, "license": "MIT", "engines": { @@ -9449,6 +10247,15 @@ "dev": true, "license": "MIT" }, + "node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -9495,15 +10302,6 @@ "uglify-js": "^3.1.4" } }, - "node_modules/handlebars/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -9523,19 +10321,6 @@ "node": ">=8" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -9548,6 +10333,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", @@ -9575,15 +10375,6 @@ "he": "bin/he" } }, - "node_modules/highlight.js": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", - "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, "node_modules/hosted-git-info": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", @@ -9663,9 +10454,9 @@ } }, "node_modules/i18n-iso-countries": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.13.0.tgz", - "integrity": "sha512-pVh4CjdgAHZswI98hzG+1BItQlsQfR+yGDsjDISoWIV/jHDAvCmSyZ5vj2YWwAjfVZ8/BhBDqWcFvuGOyHe4vg==", + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.14.0.tgz", + "integrity": "sha512-nXHJZYtNrfsi1UQbyRqm3Gou431elgLjKl//CYlnBGt5aTWdRPH1PiS2T/p/n8Q8LnqYqzQJik3Q7mkwvLokeg==", "license": "MIT", "dependencies": { "diacritics": "1.3.0" @@ -9675,9 +10466,9 @@ } }, "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.5.2.tgz", + "integrity": "sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" @@ -9717,9 +10508,9 @@ } }, "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -9733,12 +10524,12 @@ } }, "node_modules/import-in-the-middle": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.12.0.tgz", - "integrity": "sha512-yAgSE7GmtRcu4ZUSFX/4v69UGXwugFFSdIQJ14LHPOPPQrWv8Y7O9PHsw8Ovk7bKCLe4sjXMbZFqGFcLHpZ89w==", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.13.1.tgz", + "integrity": "sha512-k2V9wNm9B+ysuelDTHjI9d5KPc4l8zAZTGqj+pcynvWkypZd857ryzN8jNC7Pg2YZXNMJcHRPpaDyCBbNyVRpA==", "license": "Apache-2.0", "dependencies": { - "acorn": "^8.8.2", + "acorn": "^8.14.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^1.2.2", "module-details-from-path": "^1.0.3" @@ -9807,19 +10598,56 @@ "node": ">=12.0.0" } }, + "node_modules/inquirer/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inquirer/node_modules/cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "license": "ISC", + "engines": { + "node": ">= 10" + } + }, + "node_modules/inquirer/node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "license": "ISC" + }, + "node_modules/inquirer/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/interpret": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.10" } }, "node_modules/ioredis": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.4.2.tgz", - "integrity": "sha512-0SZXGNGZ+WzISQ67QDyZ2x0+wVxjjUndtD8oSeik/4ajifeiRufed8fCb8QW8VMyi4MXcS+UO1k/0NGhvq1PAg==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.0.tgz", + "integrity": "sha512-tBZlIIWbndeWBWCXWZiqtOF/yxf6yZX3tAlTJ7nfo5jhd6dctNxF7QnYlZLZ1a0o0pDoen7CgZqO+zjNaFbJAg==", "license": "MIT", "dependencies": { "@ioredis/commands": "^1.1.1", @@ -9884,9 +10712,9 @@ } }, "node_modules/is-core-module": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.0.tgz", - "integrity": "sha512-urTSINYfAYgcbLb0yDQ6egFm6h3Mo1DcF9EkyXSRjjzdHbsulg01qhwWuXdOoUBuTkbQ80KDboXa0vFJ+BDH+g==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -9946,6 +10774,41 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-standalone-pwa": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-standalone-pwa/-/is-standalone-pwa-0.1.1.tgz", + "integrity": "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -10062,18 +10925,19 @@ } }, "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.0.tgz", + "integrity": "sha512-9DDdhb5j6cpeitCbvLO7n7J4IxnbM6hoF6O1g4HQ5TfhvvKN8ywDM7668ZhMHRqVmxqhps/F6syWK2KcPxYlkw==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" }, + "engines": { + "node": "20 || >=22" + }, "funding": { "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" } }, "node_modules/jest-worker": { @@ -10108,9 +10972,9 @@ } }, "node_modules/jiti": { - "version": "1.21.6", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", - "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", "peer": true, "bin": { @@ -10218,9 +11082,9 @@ } }, "node_modules/jsonc-parser": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", - "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "dev": true, "license": "MIT" }, @@ -10248,34 +11112,36 @@ } }, "node_modules/kysely": { - "version": "0.27.5", - "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.27.5.tgz", - "integrity": "sha512-s7hZHcQeSNKpzCkHRm8yA+0JPLjncSWnjb+2TIElwS2JAqYr+Kv3Ess+9KFfJS0C1xcQ1i9NkNHpWwCYpHMWsA==", + "version": "0.27.6", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.27.6.tgz", + "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==", "license": "MIT", "engines": { "node": ">=14.0.0" } }, "node_modules/kysely-codegen": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/kysely-codegen/-/kysely-codegen-0.17.0.tgz", - "integrity": "sha512-C36g6epial8cIOSBEWGI9sRfkKSsEzTcivhjPivtYFQnhMdXnrVFaUe7UMZHeSdXaHiWDqDOkReJgWLD8nPKdg==", + "version": "0.18.2", + "resolved": "https://registry.npmjs.org/kysely-codegen/-/kysely-codegen-0.18.2.tgz", + "integrity": "sha512-EuhLJaofXQ2u7j8LdEBafbOKV8+Xdjgr+ywmaTF037SbAYUKmkst1dmDauzxtGp7KyYwoIDZmz7QVOsyYbb6Iw==", "dev": true, "license": "MIT", "dependencies": { "chalk": "4.1.2", - "dotenv": "^16.4.5", - "dotenv-expand": "^11.0.6", + "cosmiconfig": "^9.0.0", + "dotenv": "^16.4.7", + "dotenv-expand": "^12.0.1", "git-diff": "^2.0.6", "micromatch": "^4.0.8", "minimist": "^1.2.8", - "pluralize": "^8.0.0" + "pluralize": "^8.0.0", + "zod": "^3.24.2" }, "bin": { "kysely-codegen": "dist/cli/bin.js" }, "peerDependencies": { - "@libsql/kysely-libsql": "^0.3.0", + "@libsql/kysely-libsql": "^0.3.0 || ^0.4.1", "@tediousjs/connection-string": "^0.5.0", "better-sqlite3": ">=7.6.2", "kysely": "^0.27.0", @@ -10319,10 +11185,38 @@ } } }, + "node_modules/kysely-codegen/node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/kysely-postgres-js": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/kysely-postgres-js/-/kysely-postgres-js-2.0.0.tgz", "integrity": "sha512-R1tWx6/x3tSatWvsmbHJxpBZYhNNxcnMw52QzZaHKg7ZOWtHib4iZyEaw4gb2hNKVctWQ3jfMxZT/ZaEMK6kBQ==", + "license": "MIT", "peerDependencies": { "kysely": ">= 0.24.0 < 1", "postgres": ">= 3.4.0 < 4" @@ -10394,9 +11288,9 @@ } }, "node_modules/libphonenumber-js": { - "version": "1.11.17", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.11.17.tgz", - "integrity": "sha512-Jr6v8thd5qRlOlc6CslSTzGzzQW03uiscab7KHQZX1Dfo4R6n6FDhZ0Hri6/X7edLIDv9gl4VMZXhxTjLnl0VQ==", + "version": "1.12.5", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.5.tgz", + "integrity": "sha512-DOjiaVjjSmap12ztyb4QgoFmUe/GbgnEXHu+R7iowk0lzDIjScvPAm8cK9RYTEobbRb0OPlwlZUGTTJPJg13Kw==", "license": "MIT" }, "node_modules/lilconfig": { @@ -10502,10 +11396,11 @@ } }, "node_modules/loglevel": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz", - "integrity": "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6.0" }, @@ -10515,15 +11410,15 @@ } }, "node_modules/long": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", - "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.1.tgz", + "integrity": "sha512-ka87Jz3gcx/I7Hal94xaN2tZEOPoUOEVftkQqZx2EeQRN7LGdfLlI3FvZ+7WDplm+vK2Urx9ULrvSowtdCieng==", "license": "Apache-2.0" }, "node_modules/loupe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", - "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", + "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", "dev": true, "license": "MIT" }, @@ -10549,16 +11444,13 @@ } }, "node_modules/magic-string": { - "version": "0.30.8", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", - "integrity": "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==", + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "engines": { - "node": ">=12" + "@jridgewell/sourcemap-codec": "^1.5.0" } }, "node_modules/magicast": { @@ -10610,9 +11502,9 @@ } }, "node_modules/math-intrinsics": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.0.0.tgz", - "integrity": "sha512-4MqMiKP90ybymYvsut0CH2g4XWbfLtmlCkXmtmdcDCxNB+mQcu1w/1+L/VD7vi/PSv7X2JYV7SCcR+jiPXnQtA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -10631,12 +11523,12 @@ } }, "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/memfs": { @@ -10653,10 +11545,13 @@ } }, "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", + "engines": { + "node": ">=18" + }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } @@ -10711,34 +11606,22 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.53.0.tgz", + "integrity": "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==", "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.0.tgz", + "integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==", "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "mime-db": "^1.53.0" }, "engines": { "node": ">= 0.6" @@ -10838,9 +11721,9 @@ "license": "MIT" }, "node_modules/mock-fs": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.4.1.tgz", - "integrity": "sha512-sz/Q8K1gXXXHR+qr0GZg2ysxCRr323kuN10O7CtQjraJsFDJ4SJ+0I5MzALz7aRp9lHk8Cc/YdsT95h9Ka1aFw==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.5.0.tgz", + "integrity": "sha512-d/P1M/RacgM3dB0sJ8rjeRNXxtapkPCUnMGmIN0ixJ16F/E4GUZCvWcSGfWGz8eaXYvn1s9baUwNjI4LOPEjiA==", "dev": true, "license": "MIT", "engines": { @@ -10861,9 +11744,9 @@ "license": "BSD-3-Clause" }, "node_modules/mrmime": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", - "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", "license": "MIT", "engines": { "node": ">=10" @@ -10907,9 +11790,9 @@ } }, "node_modules/multer": { - "version": "1.4.4-lts.1", - "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4-lts.1.tgz", - "integrity": "sha512-WeSGziVj6+Z2/MwQo3GvqzgR+9Uc+qt8SwHKh3gvNPiISKfsMfG4SvCOFYlxxgkXt7yIV2i1yczehm0EOKIxIg==", + "version": "1.4.5-lts.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz", + "integrity": "sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", @@ -10939,6 +11822,36 @@ "typedarray": "^0.0.6" } }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/multer/node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -10969,17 +11882,35 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mute-stream": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", - "license": "ISC" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", "license": "MIT", + "peer": true, "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", @@ -10987,17 +11918,17 @@ } }, "node_modules/nan": { - "version": "2.22.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.0.tgz", - "integrity": "sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==", + "version": "2.22.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz", + "integrity": "sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ==", "dev": true, "license": "MIT", "optional": true }, "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.9.tgz", + "integrity": "sha512-SppoicMGpZvbF1l3z4x7No3OlIjP7QJvC9XR7AhZr1kL133KHnKPztkKDc+Ir4aJ/1VhTySrtKhrsycmrMQfvg==", "funding": [ { "type": "github", @@ -11050,9 +11981,9 @@ "license": "MIT" }, "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -11065,20 +11996,20 @@ "license": "MIT" }, "node_modules/nest-commander": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/nest-commander/-/nest-commander-3.15.0.tgz", - "integrity": "sha512-o9VEfFj/w2nm+hQi6fnkxL1GAFZW/KmuGcIE7/B/TX0gwm0QVy8svAF75EQm8wrDjcvWS7Cx/ArnkFn2C+iM2w==", + "version": "3.16.1", + "resolved": "https://registry.npmjs.org/nest-commander/-/nest-commander-3.16.1.tgz", + "integrity": "sha512-04AqAVqMKtXKvJzkjSM3fERPSY8V78zvi5pWzXyegXdmovHGnJB+/l/YUNWa1EZc0ER6tqa6eI5tYFtS4ramjQ==", "license": "MIT", "dependencies": { "@fig/complete-commander": "^3.0.0", - "@golevelup/nestjs-discovery": "4.0.1", + "@golevelup/nestjs-discovery": "4.0.3", "commander": "11.1.0", "cosmiconfig": "8.3.6", "inquirer": "8.2.6" }, "peerDependencies": { - "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", - "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", "@types/inquirer": "^8.1.3" } }, @@ -11104,44 +12035,45 @@ } }, "node_modules/nestjs-cls": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/nestjs-cls/-/nestjs-cls-4.5.0.tgz", - "integrity": "sha512-oi3GNCc5pnsnVI5WJKMDwmg4NP+JyEw+edlwgepyUba5+RGGtJzpbVaaxXGW1iPbDuQde3/fA8Jdjq9j88BVcQ==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/nestjs-cls/-/nestjs-cls-5.4.0.tgz", + "integrity": "sha512-XkD1mxctTdtlzC+4LR+7amvCGfIWghRjRBG1twczERPDUETg7Mw6RsruQoF3QrL/LKaOpMmm5OsJesTHt+hWCA==", "license": "MIT", "engines": { - "node": ">=16" + "node": ">=18" }, "peerDependencies": { - "@nestjs/common": "> 7.0.0 < 11", - "@nestjs/core": "> 7.0.0 < 11", + "@nestjs/common": ">= 10 < 12", + "@nestjs/core": ">= 10 < 12", "reflect-metadata": "*", "rxjs": ">= 7" } }, "node_modules/nestjs-kysely": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/nestjs-kysely/-/nestjs-kysely-1.0.0.tgz", - "integrity": "sha512-0XwXm3H/sGQ8DhOY1UOMuosdgii+qePJpbdlWzRPP7ueP/Z/JGnxnu2jUHurFGS+bMGPipGnk9ynIkJuM05HUw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/nestjs-kysely/-/nestjs-kysely-1.1.0.tgz", + "integrity": "sha512-89tDKHhZtMMZPzuLDcF7DM1DXjO3STwKUNt+V9bDRCuuoWc+LZDyWu9q608AfTdIPG1vgTVG1YikjpKzPhslbw==", + "license": "MIT", "peerDependencies": { - "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", - "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", "kysely": "0.x", "reflect-metadata": "^0.1.13 || ^0.2.2" } }, "node_modules/nestjs-otel": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/nestjs-otel/-/nestjs-otel-6.1.2.tgz", - "integrity": "sha512-Xs/6/3ypf8ujijM0h7lO/3kGjev1cP19FcdOw4gUc71owBu3ktf0k3BXvo0lxb/vP+37j9Ljl6lf2cJtwJ9h5A==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/nestjs-otel/-/nestjs-otel-6.2.0.tgz", + "integrity": "sha512-F15GnWNrmHxDRdn0o2/cDx65gR7+s3xou1mEJ5vVONfOOYeneIJi1Mkf6h/Qu6NfO4SHPFPKGMXoovvTX1D8Iw==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/host-metrics": "^0.35.4", + "@opentelemetry/host-metrics": "^0.35.5", "response-time": "^2.3.3" }, "peerDependencies": { - "@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0", - "@nestjs/core": "^9.0.0 || ^10.0.0 || ^11.0.0" + "@nestjs/common": ">= 10 < 12", + "@nestjs/core": ">= 10 < 12" } }, "node_modules/next": { @@ -11205,9 +12137,9 @@ "license": "MIT" }, "node_modules/node-addon-api": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.3.0.tgz", - "integrity": "sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg==", + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.3.1.tgz", + "integrity": "sha512-lytcDEdxKjGJPTLEfW4mYMigRezMlyJY8W4wxJK8zE533Jlb8L8dRuObJFWg2P+AuOIxoCgKF+2Oq4d4Zd0OUA==", "dev": true, "license": "MIT", "engines": { @@ -11266,9 +12198,9 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "6.9.16", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.16.tgz", - "integrity": "sha512-psAuZdTIRN08HKVd/E8ObdV6NO7NTBY3KsC30F7M4H1OnmLCUNaS56FpYxyb26zWLSyYF9Ozch9KYHhHegsiOQ==", + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.0.tgz", + "integrity": "sha512-SQ3wZCExjeSatLE/HBaXS5vqUOQk6GtBdIIKxiFdmm01mOQZX/POJkO3SUX1wDiYcwUOJwT23scFSC9fY2H8IA==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -11359,9 +12291,9 @@ } }, "node_modules/object-inspect": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", - "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -11371,9 +12303,9 @@ } }, "node_modules/oidc-token-hash": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", - "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz", + "integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==", "license": "MIT", "engines": { "node": "^10.13.0 || >=12.0.0" @@ -11489,6 +12421,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/os-tmpdir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", @@ -11576,25 +12529,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse5": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", - "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", - "license": "MIT" - }, - "node_modules/parse5-htmlparser2-tree-adapter": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", - "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", - "license": "MIT", - "dependencies": { - "parse5": "^6.0.1" - } - }, - "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", - "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", "license": "MIT" }, "node_modules/parseley": { @@ -11654,26 +12592,31 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.2.tgz", + "integrity": "sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } }, "node_modules/path-source": { "version": "0.1.3", @@ -11686,10 +12629,13 @@ } }, "node_modules/path-to-regexp": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", - "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", - "license": "MIT" + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } }, "node_modules/path-type": { "version": "4.0.0", @@ -11701,9 +12647,9 @@ } }, "node_modules/pathe": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.2.tgz", - "integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, @@ -11740,14 +12686,14 @@ } }, "node_modules/pg": { - "version": "8.13.1", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.1.tgz", - "integrity": "sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==", + "version": "8.13.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.3.tgz", + "integrity": "sha512-P6tPt9jXbL9HVu/SSRERNYaYG++MjnscnegFh9pPHihfoBSujsrka0hyuymMzeJKFWrcG8wvCKy8rCe8e5nDUQ==", "license": "MIT", "dependencies": { "pg-connection-string": "^2.7.0", - "pg-pool": "^3.7.0", - "pg-protocol": "^1.7.0", + "pg-pool": "^3.7.1", + "pg-protocol": "^1.7.1", "pg-types": "^2.1.0", "pgpass": "1.x" }, @@ -11789,18 +12735,18 @@ } }, "node_modules/pg-pool": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz", - "integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==", + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.1.tgz", + "integrity": "sha512-xIOsFoh7Vdhojas6q3596mXFsR8nwBQBXX5JiV7p9buEVAGqYL4yFzclON5P9vFrpu1u7Zwl2oriyDa89n0wbw==", "license": "MIT", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.0.tgz", - "integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.1.tgz", + "integrity": "sha512-gjTHWGYWsEgy9MsY0Gp6ZJxV24IjDqdpTW7Eh0x+WfJLFsm/TJx1MzL6T0D88mBvkpxotCQ6TwW6N+Kko7lhgQ==", "license": "MIT" }, "node_modules/pg-types": { @@ -11887,9 +12833,9 @@ } }, "node_modules/point-in-polygon-hao": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/point-in-polygon-hao/-/point-in-polygon-hao-1.2.3.tgz", - "integrity": "sha512-uZsWylGd8nthIYS8F7aSyM7Pot+4L/bgXheJcCNdRr4eLpsM/rMb3hIi5SqNxAVjUoDDao3QzCtdaVDzmeF9Cw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/point-in-polygon-hao/-/point-in-polygon-hao-1.2.4.tgz", + "integrity": "sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==", "license": "MIT", "dependencies": { "robust-predicates": "^3.0.2" @@ -12045,9 +12991,10 @@ "peer": true }, "node_modules/postgres": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.4.tgz", - "integrity": "sha512-IbyN+9KslkqcXa8AO9fxpk97PA4pzewvpi2B3Dwy9u4zpV32QicaEdgmF3eSQUzdRk7ttDHQejNgAEr4XoeH4A==", + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.5.tgz", + "integrity": "sha512-cDWgoah1Gez9rN3H4165peY9qfpEo+SA61oQv65O3cRUE1pOEoJWwddwcqKE8XZYjbblOJlYDlLV4h67HrEVDg==", + "license": "Unlicense", "peer": true, "engines": { "node": ">=12" @@ -12107,9 +13054,9 @@ } }, "node_modules/prettier": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", - "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" @@ -12323,12 +13270,6 @@ ], "license": "MIT" }, - "node_modules/queue-tick": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", - "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==", - "license": "MIT" - }, "node_modules/railroad-diagrams": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", @@ -12370,20 +13311,32 @@ } }, "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", "license": "MIT", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", - "iconv-lite": "0.4.24", + "iconv-lite": "0.6.3", "unpipe": "1.0.0" }, "engines": { "node": ">= 0.8" } }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", @@ -12407,9 +13360,9 @@ } }, "node_modules/react-email": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/react-email/-/react-email-3.0.6.tgz", - "integrity": "sha512-taTvHORG2bCZCvUgVkRV0hTJJ5I40UKcmMuHzEhDOBNVh3/CCvIv4jRuD2EheSU1c4hFxxiUyanphb+qUQWeBw==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/react-email/-/react-email-3.0.7.tgz", + "integrity": "sha512-lX9dFCPtTG+79aP9uTdx763byshptPYbOi0KXwxn6nPJoDP/Ty/G1W5fx1lbrmec+pk38MTDZPrzJ/UYIxgP/Q==", "license": "MIT", "dependencies": { "@babel/core": "7.24.5", @@ -12418,14 +13371,14 @@ "chokidar": "4.0.3", "commander": "11.1.0", "debounce": "2.0.0", - "esbuild": "0.19.11", + "esbuild": "0.23.0", "glob": "10.3.4", "log-symbols": "4.1.0", "mime-types": "2.1.35", "next": "15.1.2", "normalize-path": "3.0.0", "ora": "5.4.1", - "socket.io": "4.8.0" + "socket.io": "4.8.1" }, "bin": { "email": "dist/cli/index.js" @@ -12479,23 +13432,6 @@ "node": ">=16" } }, - "node_modules/react-email/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/react-email/node_modules/glob": { "version": "10.3.4", "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.4.tgz", @@ -12536,6 +13472,33 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/react-email/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/react-email/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/react-email/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/react-email/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -12551,10 +13514,26 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/react-email/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/react-email/node_modules/readdirp": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.1.tgz", - "integrity": "sha512-h80JrZu/MHUZCyHu5ciuoI0+WxsCxzxJTILn6Fs8rxSnFPh+UVHYfeIxK1nVGugMqkfC4vJcBOYbkfkwYK0+gw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -12564,24 +13543,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/react-email/node_modules/socket.io": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.0.tgz", - "integrity": "sha512-8U6BEgGjQOfGz3HHTYaC/L1GaxDCJ/KM0XTkJly0EhZ5U/du9uNEZy4ZgYzEzIqlx2CMm25CrCqr1ck899eLNA==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.4", - "base64id": "~2.0.0", - "cors": "~2.8.5", - "debug": "~4.3.2", - "engine.io": "~6.6.0", - "socket.io-adapter": "~2.5.2", - "socket.io-parser": "~4.2.4" - }, - "engines": { - "node": ">=10.2.0" - } - }, "node_modules/react-promise-suspense": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz", @@ -12718,9 +13679,9 @@ } }, "node_modules/readable-stream": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", - "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", "license": "MIT", "dependencies": { "abort-controller": "^3.0.0", @@ -12912,9 +13873,9 @@ } }, "node_modules/require-in-the-middle": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.4.0.tgz", - "integrity": "sha512-X34iHADNbNDfr6OTStIAHWSAvvKQRYgLO6duASaVf7J2VA3lvmNYboAHOuLC2huav1IwgZJtyEcJCKVzFxOSMQ==", + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz", + "integrity": "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==", "license": "MIT", "dependencies": { "debug": "^4.3.5", @@ -12926,9 +13887,9 @@ } }, "node_modules/resolve": { - "version": "1.22.9", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.9.tgz", - "integrity": "sha512-QxrmX1DzraFIi9PxdG5VkRfRwIgjwyud+z/iBwfRRrVmHc+P9Q7u2lSSpQ6bjr2gy5lrqIiU9vb6iAeGf2400A==", + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", "license": "MIT", "dependencies": { "is-core-module": "^2.16.0", @@ -12938,6 +13899,9 @@ "bin": { "resolve": "bin/resolve" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -13013,9 +13977,9 @@ } }, "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -13042,99 +14006,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.0.tgz", - "integrity": "sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^4.0.1", - "minimatch": "^10.0.0", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/jackspeak": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.0.2.tgz", - "integrity": "sha512-bZsjR/iRjl1Nk1UkjGpAzLNfQtzuijhn2g+pbZb98HQ1Gk8vM9hfbxeMBP+M2/UUdwj0RqGG3mlvk2MsAqwvEw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/lru-cache": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.2.tgz", - "integrity": "sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", - "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/path-scurry": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", - "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/robust-predicates": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", @@ -13142,9 +14013,9 @@ "license": "Unlicense" }, "node_modules/rollup": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.28.1.tgz", - "integrity": "sha512-61fXYl/qNVinKmGSTHAZ6Yy8I3YIJC/r2m9feHo6SwVAVcLT5MPwOUFe7EuURA/4m0NR8lXG4BBXuo/IZEsjMg==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.9.tgz", + "integrity": "sha512-nF5XYqWWp9hx/LrpC8sZvvvmq0TeTjQgaZHYmAgwysT9nh8sWnZhBnM8ZyVbbJFIQBLwHDNoMqsBZBbUo4U8sQ==", "dev": true, "license": "MIT", "dependencies": { @@ -13158,28 +14029,42 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.28.1", - "@rollup/rollup-android-arm64": "4.28.1", - "@rollup/rollup-darwin-arm64": "4.28.1", - "@rollup/rollup-darwin-x64": "4.28.1", - "@rollup/rollup-freebsd-arm64": "4.28.1", - "@rollup/rollup-freebsd-x64": "4.28.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.28.1", - "@rollup/rollup-linux-arm-musleabihf": "4.28.1", - "@rollup/rollup-linux-arm64-gnu": "4.28.1", - "@rollup/rollup-linux-arm64-musl": "4.28.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.28.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.28.1", - "@rollup/rollup-linux-riscv64-gnu": "4.28.1", - "@rollup/rollup-linux-s390x-gnu": "4.28.1", - "@rollup/rollup-linux-x64-gnu": "4.28.1", - "@rollup/rollup-linux-x64-musl": "4.28.1", - "@rollup/rollup-win32-arm64-msvc": "4.28.1", - "@rollup/rollup-win32-ia32-msvc": "4.28.1", - "@rollup/rollup-win32-x64-msvc": "4.28.1", + "@rollup/rollup-android-arm-eabi": "4.34.9", + "@rollup/rollup-android-arm64": "4.34.9", + "@rollup/rollup-darwin-arm64": "4.34.9", + "@rollup/rollup-darwin-x64": "4.34.9", + "@rollup/rollup-freebsd-arm64": "4.34.9", + "@rollup/rollup-freebsd-x64": "4.34.9", + "@rollup/rollup-linux-arm-gnueabihf": "4.34.9", + "@rollup/rollup-linux-arm-musleabihf": "4.34.9", + "@rollup/rollup-linux-arm64-gnu": "4.34.9", + "@rollup/rollup-linux-arm64-musl": "4.34.9", + "@rollup/rollup-linux-loongarch64-gnu": "4.34.9", + "@rollup/rollup-linux-powerpc64le-gnu": "4.34.9", + "@rollup/rollup-linux-riscv64-gnu": "4.34.9", + "@rollup/rollup-linux-s390x-gnu": "4.34.9", + "@rollup/rollup-linux-x64-gnu": "4.34.9", + "@rollup/rollup-linux-x64-musl": "4.34.9", + "@rollup/rollup-win32-arm64-msvc": "4.34.9", + "@rollup/rollup-win32-ia32-msvc": "4.34.9", + "@rollup/rollup-win32-x64-msvc": "4.34.9", "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.1.0.tgz", + "integrity": "sha512-/m/NSLxeYEgWNtyC+WtNHCF7jbGxOibVWKnn+1Psff4dJGOfoXP+MuC/f2CwSmyiHdOIzYnYFp4W6GxWfekaLA==", + "license": "MIT", + "dependencies": { + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -13213,9 +14098,9 @@ } }, "node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" @@ -13256,6 +14141,20 @@ "truncate-utf8-bytes": "^1.0.0" } }, + "node_modules/sanitize-html": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.14.0.tgz", + "integrity": "sha512-CafX+IUPxZshXqqRaG9ZClSlfPVjSxI0td7n07hk8QO2oO+9JDnlcL8iM8TWeOXOIBFgIOx6zioTzM53AOMn3g==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^8.0.0", + "is-plain-object": "^5.0.0", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + } + }, "node_modules/scheduler": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", @@ -13295,9 +14194,9 @@ } }, "node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -13307,51 +14206,56 @@ } }, "node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.1.0.tgz", + "integrity": "sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" + "debug": "^4.3.5", + "destroy": "^1.2.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^0.5.2", + "http-errors": "^2.0.0", + "mime-types": "^2.1.35", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 18" } }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/send/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "node_modules/send/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" } }, "node_modules/serialize-javascript": { @@ -13365,18 +14269,18 @@ } }, "node_modules/serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.1.0.tgz", + "integrity": "sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==", "license": "MIT", "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.19.0" + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.0.0" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 18" } }, "node_modules/set-blocking": { @@ -13385,24 +14289,6 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "license": "ISC" }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -13511,6 +14397,7 @@ "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.5.tgz", "integrity": "sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "glob": "^7.0.0", "interpret": "^1.0.0", @@ -13528,6 +14415,7 @@ "resolved": "https://registry.npmjs.org/shelljs.exec/-/shelljs.exec-1.1.8.tgz", "integrity": "sha512-vFILCw+lzUtiwBAHV8/Ex8JsFjelFMdhONIsgKNLgTzeRckp2AOYRQtHJE/9LhNvdMmE27AGtzWx0+DHpwIwSw==", "dev": true, + "license": "ISC", "engines": { "node": ">= 4.0.0" } @@ -13538,6 +14426,7 @@ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, + "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -13666,9 +14555,9 @@ "license": "MIT" }, "node_modules/sirv": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.0.tgz", - "integrity": "sha512-BPwJGUeDaDCHihkORDchNyyTvWFhcusy1XMmhEVTQTwGeybFbp8YEmB+njbPnth1FibULBSBVwCQni25XlCUDg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz", + "integrity": "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==", "license": "MIT", "dependencies": { "@polka/url": "^1.0.0-next.24", @@ -13760,6 +14649,19 @@ } } }, + "node_modules/socket.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/socket.io/node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", @@ -13777,14 +14679,43 @@ } } }, + "node_modules/socket.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", - "dev": true, + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "license": "BSD-3-Clause", "engines": { - "node": ">= 8" + "node": ">=0.10.0" } }, "node_modules/source-map-js": { @@ -13807,16 +14738,6 @@ "source-map": "^0.6.0" } }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -13847,9 +14768,9 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.20", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.20.tgz", - "integrity": "sha512-jg25NiDV/1fLtSgEgyvVyDunvaNHbuwF9lfNV17gSmPFAlYzdfNBlLtLzXTevwkPj7DhGbmN9VnmJIgLnhvaBw==", + "version": "3.0.21", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz", + "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==", "dev": true, "license": "CC0-1.0" }, @@ -13870,9 +14791,9 @@ } }, "node_modules/sql-formatter": { - "version": "15.4.9", - "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.4.9.tgz", - "integrity": "sha512-5vmt2HlCAVozxsBZuXWkAki/KGawaK+b5GG5x+BtXOFVpN/8cqppblFUxHl4jxdA0cvo14lABhM+KBnrUapOlw==", + "version": "15.4.11", + "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.4.11.tgz", + "integrity": "sha512-AfIjH0mYxv0NVzs4mbcGIAcos2Si20LeF9GMk0VmVA4A3gs1PFIixVu3rtcz34ls7ghPAjrDb+XbRly/aF6HAg==", "dev": true, "license": "MIT", "dependencies": { @@ -13884,6 +14805,22 @@ "sql-formatter": "bin/sql-formatter-cli.cjs" } }, + "node_modules/sql-highlight": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/sql-highlight/-/sql-highlight-6.0.0.tgz", + "integrity": "sha512-+fLpbAbWkQ+d0JEchJT/NrRRXbYRNbG15gFpANx73EwxQB1PRjj+k/OI0GTU0J63g8ikGkJECQp9z8XEJZvPRw==", + "funding": [ + "https://github.com/scriptcoded/sql-highlight?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/scriptcoded" + } + ], + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/ssh-remote-port-forward": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/ssh-remote-port-forward/-/ssh-remote-port-forward-1.0.4.tgz", @@ -13947,9 +14884,9 @@ } }, "node_modules/std-env": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.0.tgz", - "integrity": "sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.1.tgz", + "integrity": "sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA==", "dev": true, "license": "MIT" }, @@ -13968,13 +14905,12 @@ } }, "node_modules/streamx": { - "version": "2.21.1", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.21.1.tgz", - "integrity": "sha512-PhP9wUnFLa+91CPy3N6tiQsK+gnYyUNuk15S3YG/zjYE7RuPeCjJngqnzpC31ow0lzBHQ+QGO4cNJnd0djYUsw==", + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz", + "integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==", "license": "MIT", "dependencies": { "fast-fifo": "^1.3.2", - "queue-tick": "^1.0.1", "text-decoder": "^1.1.0" }, "optionalDependencies": { @@ -14019,7 +14955,16 @@ "node": ">=8" } }, - "node_modules/strip-ansi": { + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", @@ -14031,6 +14976,42 @@ "node": ">=8" } }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/strip-ansi-cjs": { "name": "strip-ansi", "version": "6.0.1", @@ -14044,6 +15025,15 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -14126,6 +15116,93 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/sucrase/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", + "peer": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/sucrase/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC", + "peer": true + }, + "node_modules/sucrase/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -14151,9 +15228,9 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.18.2", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.18.2.tgz", - "integrity": "sha512-J+y4mCw/zXh1FOj5wGJvnAajq6XgHOyywsa9yITmwxIlJbMqITq3gYRZHaeqLVH/eV/HOPphE6NjF+nbSNC5Zw==", + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.19.0.tgz", + "integrity": "sha512-bSVZeYaqanMFeW5ZY3+EejFbsjkjazYxm1I7Lz3xayYz5XU3m2aUzvuPC0jI95WCQdduszHYV3ER4buQoy8DXA==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" @@ -14187,9 +15264,9 @@ } }, "node_modules/systeminformation": { - "version": "5.22.9", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.22.9.tgz", - "integrity": "sha512-qUWJhQ9JSBhdjzNUQywpvc0icxUAjMY3sZqUoS0GOtaJV9Ijq8s9zEP8Gaqmymn1dOefcICyPXK1L3kgKxlUpg==", + "version": "5.23.8", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.23.8.tgz", + "integrity": "sha512-Osd24mNKe6jr/YoXLLK3k8TMdzaxDffhpCxgkfgBHcapykIkd50HXThM3TCEuHO2pPuCsSx2ms/SunqhU5MmsQ==", "license": "MIT", "os": [ "darwin", @@ -14251,27 +15328,27 @@ } }, "node_modules/tailwindcss-email-variants": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tailwindcss-email-variants/-/tailwindcss-email-variants-3.0.3.tgz", - "integrity": "sha512-gchBYFNprLfRtmxnrglF4tayxFbv+hBV+3obXQycrBcluLj5CQF8uJsZH6ir0aIGQXfh5ukMdIkEgKOBzrBYxA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/tailwindcss-email-variants/-/tailwindcss-email-variants-3.0.4.tgz", + "integrity": "sha512-ohtLSifyWQDAtddJnfbcxkIDCIyXp6Yb83hXRprrS+/2dSyme4OlUZAP+TDwQc0K8D0LAw80eKI6psgejxys8A==", "license": "MIT", "engines": { "node": ">=18" }, "peerDependencies": { - "tailwindcss": ">=3.4.0" + "tailwindcss": ">=3.4.0 < 4" } }, "node_modules/tailwindcss-mso": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/tailwindcss-mso/-/tailwindcss-mso-2.0.1.tgz", - "integrity": "sha512-77QvlGNqduGCtwTjLJog+PLD5YMNRR6FdbBTS6DcfbmO+9q0rSLgy/0y70wZ/jbDx152g6i5w3noFpHq8hzYPw==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/tailwindcss-mso/-/tailwindcss-mso-2.0.2.tgz", + "integrity": "sha512-GaR8RW/Kan+YWEQ9Y9Ah6AYy7R2wEQ3X++YK4ffJVWycCTd6ryMLezqmyhi7KWHqsgQOb4nhjJYayI+JF44BXw==", "license": "MIT", "engines": { "node": ">=18.20" }, "peerDependencies": { - "tailwindcss": ">=3.4.0" + "tailwindcss": ">=3.4.0 < 4" } }, "node_modules/tailwindcss-preset-email": { @@ -14301,9 +15378,9 @@ } }, "node_modules/tailwindcss/node_modules/postcss": { - "version": "8.4.49", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", - "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", "funding": [ { "type": "opencollective", @@ -14321,7 +15398,7 @@ "license": "MIT", "peer": true, "dependencies": { - "nanoid": "^3.3.7", + "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -14357,9 +15434,9 @@ } }, "node_modules/tar-fs": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.6.tgz", - "integrity": "sha512-iokBDQQkUyeXhgPYaZxmczGPhnhXZ0CmrqI+MOb/WFGS9DW5wnfrLgtjUJBvz50vQ3qfRwJ62QVoCFu8mPVu5w==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz", + "integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==", "dev": true, "license": "MIT", "dependencies": { @@ -14367,8 +15444,8 @@ "tar-stream": "^3.1.5" }, "optionalDependencies": { - "bare-fs": "^2.1.1", - "bare-path": "^2.1.0" + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" } }, "node_modules/tar-stream": { @@ -14404,9 +15481,9 @@ } }, "node_modules/terser": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.37.0.tgz", - "integrity": "sha512-B8wRRkmre4ERucLM/uXx4MOV5cbnOlVAqUst+1+iLKPI0dOgFO28f84ptoQt9HEI537PMzfYa/d+GEPKTRXmYA==", + "version": "5.39.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz", + "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -14423,9 +15500,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.11", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.11.tgz", - "integrity": "sha512-RVCsMfuD0+cTt3EwX8hSl2Ks56EbFHWmhluwcqoPKtBnfjiT6olaq7PRIRfhyU8nnC2MrnDrBLfrD/RGE+cVXQ==", + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", + "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", "dev": true, "license": "MIT", "dependencies": { @@ -14474,6 +15551,24 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/terser-webpack-plugin/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", @@ -14546,6 +15641,50 @@ "balanced-match": "^1.0.0" } }, + "node_modules/test-exclude/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/test-exclude/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/test-exclude/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -14562,10 +15701,27 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/test-exclude/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/testcontainers": { - "version": "10.16.0", - "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.16.0.tgz", - "integrity": "sha512-oxPLuOtrRWS11A+Yn0+zXB7GkmNarflWqmy6CQJk8KJ75LZs2/zlUXDpizTbPpCGtk4kE2EQYwFZjrE967F8Wg==", + "version": "10.18.0", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.18.0.tgz", + "integrity": "sha512-MnwWsPjsN5QVe+lSU1LwLZVOyjgwSwv1INzkw8FekdwgvOtvJ7FThQEkbmzRcguQootgwmA9FG54NoTChZDRvA==", "dev": true, "license": "MIT", "dependencies": { @@ -14583,7 +15739,7 @@ "ssh-remote-port-forward": "^1.0.4", "tar-fs": "^3.0.6", "tmp": "^0.2.3", - "undici": "^5.28.4" + "undici": "^5.28.5" } }, "node_modules/testcontainers/node_modules/tmp": { @@ -14617,6 +15773,7 @@ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", "license": "MIT", + "peer": true, "dependencies": { "any-promise": "^1.0.0" } @@ -14626,6 +15783,7 @@ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", "license": "MIT", + "peer": true, "dependencies": { "thenify": ">= 3.1.0 < 4" }, @@ -14757,9 +15915,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.0.tgz", - "integrity": "sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz", + "integrity": "sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==", "dev": true, "license": "MIT", "engines": { @@ -14777,9 +15935,9 @@ "peer": true }, "node_modules/tsconfck": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.4.tgz", - "integrity": "sha512-kdqWFGVJqe+KGYvlSO9NIaWn9jT1Ny4oKVzAJsKii5eoE9snzTJzL4+MMVOMn+fikWGFmKEylcXL710V/kIPJQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.5.tgz", + "integrity": "sha512-CLDfGgUp7XPswWnezWwsCRxNmgQjhYq3VXHM0/XIRxhVrKw0M1if9agzryh1QS3nxjCROvV+xWxoJO1YctzzWg==", "dev": true, "license": "MIT", "bin": { @@ -14867,13 +16025,14 @@ } }, "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.0.tgz", + "integrity": "sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==", "license": "MIT", "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" }, "engines": { "node": ">= 0.6" @@ -14886,25 +16045,23 @@ "license": "MIT" }, "node_modules/typeorm": { - "version": "0.3.20", - "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.20.tgz", - "integrity": "sha512-sJ0T08dV5eoZroaq9uPKBoNcGslHBR4E4y+EBHs//SiGbblGe7IeduP/IH4ddCcj0qp3PHwDwGnuvqEAnKlq/Q==", + "version": "0.3.21", + "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.21.tgz", + "integrity": "sha512-lh4rUWl1liZGjyPTWpwcK8RNI5x4ekln+/JJOox1wCd7xbucYDOXWD+1cSzTN3L0wbTGxxOtloM5JlxbOxEufA==", "license": "MIT", "dependencies": { "@sqltools/formatter": "^1.2.5", + "ansis": "^3.9.0", "app-root-path": "^3.1.0", "buffer": "^6.0.3", - "chalk": "^4.1.2", - "cli-highlight": "^2.1.11", "dayjs": "^1.11.9", "debug": "^4.3.4", "dotenv": "^16.0.3", - "glob": "^10.3.10", - "mkdirp": "^2.1.3", - "reflect-metadata": "^0.2.1", + "glob": "^10.4.5", "sha.js": "^2.4.11", + "sql-highlight": "^6.0.0", "tslib": "^2.5.0", - "uuid": "^9.0.0", + "uuid": "^11.0.5", "yargs": "^17.6.2" }, "bin": { @@ -14921,17 +16078,18 @@ "peerDependencies": { "@google-cloud/spanner": "^5.18.0", "@sap/hana-client": "^2.12.25", - "better-sqlite3": "^7.1.2 || ^8.0.0 || ^9.0.0", + "better-sqlite3": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", "hdb-pool": "^0.1.6", "ioredis": "^5.0.4", "mongodb": "^5.8.0", - "mssql": "^9.1.1 || ^10.0.1", + "mssql": "^9.1.1 || ^10.0.1 || ^11.0.1", "mysql2": "^2.2.5 || ^3.0.1", "oracledb": "^6.3.0", "pg": "^8.5.1", "pg-native": "^3.0.0", "pg-query-stream": "^4.0.0", "redis": "^3.1.1 || ^4.0.0", + "reflect-metadata": "^0.1.14 || ^0.2.0", "sql.js": "^1.4.0", "sqlite3": "^5.0.3", "ts-node": "^10.7.0", @@ -14991,6 +16149,15 @@ } } }, + "node_modules/typeorm/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/typeorm/node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -15015,38 +16182,95 @@ "ieee754": "^1.2.1" } }, - "node_modules/typeorm/node_modules/mkdirp": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-2.1.6.tgz", - "integrity": "sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==", - "license": "MIT", + "node_modules/typeorm/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, "bin": { - "mkdirp": "dist/cjs/src/bin.js" + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typeorm/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/typeorm/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/typeorm/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=10" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typeorm/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/typeorm/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/typescript": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", - "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "devOptional": true, "license": "Apache-2.0", "bin": { @@ -15057,10 +16281,30 @@ "node": ">=14.17" } }, + "node_modules/ua-is-frozen": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ua-is-frozen/-/ua-is-frozen-0.1.2.tgz", + "integrity": "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, "node_modules/ua-parser-js": { - "version": "1.0.40", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.40.tgz", - "integrity": "sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-2.0.2.tgz", + "integrity": "sha512-NoaPjzMmuUlo5bJ2jrdkzvHL8gpcgVrhUugAqsqsundDO3R8rw7R0OwxLoWhcKtsTb+6u3z9dES8m6+vxEcJog==", "funding": [ { "type": "opencollective", @@ -15075,7 +16319,14 @@ "url": "https://github.com/sponsors/faisalman" } ], - "license": "MIT", + "license": "AGPL-3.0-or-later", + "dependencies": { + "@types/node-fetch": "^2.6.12", + "detect-europe-js": "^0.1.2", + "is-standalone-pwa": "^0.1.1", + "node-fetch": "^2.7.0", + "ua-is-frozen": "^0.1.2" + }, "bin": { "ua-parser-js": "script/cli.js" }, @@ -15118,9 +16369,9 @@ } }, "node_modules/undici": { - "version": "5.28.4", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", - "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", + "version": "5.28.5", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.5.tgz", + "integrity": "sha512-zICwjrDrcrUE0pyyJc1I2QzBkLM8FINsgOrt6WjA+BgajVq9Nxu2PbFFXUrAggLfDXlZGZBVZYw7WNV5KiBiBA==", "dev": true, "license": "MIT", "dependencies": { @@ -15156,9 +16407,9 @@ } }, "node_modules/unplugin": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.16.0.tgz", - "integrity": "sha512-5liCNPuJW8dqh3+DM6uNM2EI3MLLpCKp/KY+9pB5M2S2SR2qvvDHhKgBOaTWEbZTAws3CXfB0rKTIolWKL05VQ==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.16.1.tgz", + "integrity": "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==", "dev": true, "license": "MIT", "dependencies": { @@ -15185,9 +16436,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", - "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", "funding": [ { "type": "opencollective", @@ -15205,7 +16456,7 @@ "license": "MIT", "dependencies": { "escalade": "^3.2.0", - "picocolors": "^1.1.0" + "picocolors": "^1.1.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -15267,6 +16518,19 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -15297,21 +16561,21 @@ } }, "node_modules/vite": { - "version": "5.4.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", - "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.1.tgz", + "integrity": "sha512-n2GnqDb6XPhlt9B8olZPrgMD/es/Nd1RdChF6CBD/fHW6pUyUTt2sQW2fPRX5GiD9XEa6+8A6A4f2vT6pSsE7Q==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "esbuild": "^0.25.0", + "postcss": "^8.5.3", + "rollup": "^4.30.1" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -15320,19 +16584,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", - "terser": "^5.4.0" + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -15353,20 +16623,26 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, "node_modules/vite-node": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.3.tgz", - "integrity": "sha512-0sQcwhwAEw/UJGojbhOrnq3HtiZ3tC7BzpAa0lx3QaTX0S3YX70iGcik25UBdB96pmdwjyY2uyKNYruxCDmiEg==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.8.tgz", + "integrity": "sha512-6PhR4H9VGlcwXZ+KWCdMqbtG649xCPZqfI9j2PsK1FcXgEzro5bGHcVKFCTqPLaNKZES8Evqv4LwvZARsq5qlg==", "dev": true, "license": "MIT", "dependencies": { "cac": "^6.7.14", "debug": "^4.4.0", "es-module-lexer": "^1.6.0", - "pathe": "^2.0.1", + "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0" }, "bin": { @@ -15400,9 +16676,9 @@ } }, "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", + "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", "cpu": [ "ppc64" ], @@ -15413,13 +16689,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", + "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", "cpu": [ "arm" ], @@ -15430,13 +16706,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", + "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", "cpu": [ "arm64" ], @@ -15447,13 +16723,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", + "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", "cpu": [ "x64" ], @@ -15464,13 +16740,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", + "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", "cpu": [ "arm64" ], @@ -15481,13 +16757,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", + "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", "cpu": [ "x64" ], @@ -15498,13 +16774,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", + "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", "cpu": [ "arm64" ], @@ -15515,13 +16791,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", + "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", "cpu": [ "x64" ], @@ -15532,13 +16808,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", + "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", "cpu": [ "arm" ], @@ -15549,13 +16825,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", + "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", "cpu": [ "arm64" ], @@ -15566,13 +16842,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", + "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", "cpu": [ "ia32" ], @@ -15583,13 +16859,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", + "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", "cpu": [ "loong64" ], @@ -15600,13 +16876,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", + "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", "cpu": [ "mips64el" ], @@ -15617,13 +16893,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", + "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", "cpu": [ "ppc64" ], @@ -15634,13 +16910,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", + "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", "cpu": [ "riscv64" ], @@ -15651,13 +16927,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", + "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", "cpu": [ "s390x" ], @@ -15668,13 +16944,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", + "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", "cpu": [ "x64" ], @@ -15685,13 +16961,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", + "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", "cpu": [ "x64" ], @@ -15702,13 +16978,30 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", + "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", + "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", "cpu": [ "x64" ], @@ -15719,13 +17012,13 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", + "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", "cpu": [ "x64" ], @@ -15736,13 +17029,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", + "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", "cpu": [ "arm64" ], @@ -15753,13 +17046,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", + "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", "cpu": [ "ia32" ], @@ -15770,13 +17063,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", + "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", "cpu": [ "x64" ], @@ -15787,13 +17080,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/vite/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", + "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -15801,38 +17094,40 @@ "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" + "@esbuild/aix-ppc64": "0.25.0", + "@esbuild/android-arm": "0.25.0", + "@esbuild/android-arm64": "0.25.0", + "@esbuild/android-x64": "0.25.0", + "@esbuild/darwin-arm64": "0.25.0", + "@esbuild/darwin-x64": "0.25.0", + "@esbuild/freebsd-arm64": "0.25.0", + "@esbuild/freebsd-x64": "0.25.0", + "@esbuild/linux-arm": "0.25.0", + "@esbuild/linux-arm64": "0.25.0", + "@esbuild/linux-ia32": "0.25.0", + "@esbuild/linux-loong64": "0.25.0", + "@esbuild/linux-mips64el": "0.25.0", + "@esbuild/linux-ppc64": "0.25.0", + "@esbuild/linux-riscv64": "0.25.0", + "@esbuild/linux-s390x": "0.25.0", + "@esbuild/linux-x64": "0.25.0", + "@esbuild/netbsd-arm64": "0.25.0", + "@esbuild/netbsd-x64": "0.25.0", + "@esbuild/openbsd-arm64": "0.25.0", + "@esbuild/openbsd-x64": "0.25.0", + "@esbuild/sunos-x64": "0.25.0", + "@esbuild/win32-arm64": "0.25.0", + "@esbuild/win32-ia32": "0.25.0", + "@esbuild/win32-x64": "0.25.0" } }, "node_modules/vite/node_modules/postcss": { - "version": "8.4.49", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", - "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", "dev": true, "funding": [ { @@ -15850,7 +17145,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", + "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -15859,31 +17154,31 @@ } }, "node_modules/vitest": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.3.tgz", - "integrity": "sha512-dWdwTFUW9rcnL0LyF2F+IfvNQWB0w9DERySCk8VMG75F8k25C7LsZoh6XfCjPvcR8Nb+Lqi9JKr6vnzH7HSrpQ==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.8.tgz", + "integrity": "sha512-dfqAsNqRGUc8hB9OVR2P0w8PZPEckti2+5rdZip0WIz9WW0MnImJ8XiR61QhqLa92EQzKP2uPkzenKOAHyEIbA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "3.0.3", - "@vitest/mocker": "3.0.3", - "@vitest/pretty-format": "^3.0.3", - "@vitest/runner": "3.0.3", - "@vitest/snapshot": "3.0.3", - "@vitest/spy": "3.0.3", - "@vitest/utils": "3.0.3", - "chai": "^5.1.2", + "@vitest/expect": "3.0.8", + "@vitest/mocker": "3.0.8", + "@vitest/pretty-format": "^3.0.8", + "@vitest/runner": "3.0.8", + "@vitest/snapshot": "3.0.8", + "@vitest/spy": "3.0.8", + "@vitest/utils": "3.0.8", + "chai": "^5.2.0", "debug": "^4.4.0", "expect-type": "^1.1.0", "magic-string": "^0.30.17", - "pathe": "^2.0.1", + "pathe": "^2.0.3", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.0.3", + "vite-node": "3.0.8", "why-is-node-running": "^2.3.0" }, "bin": { @@ -15897,9 +17192,10 @@ }, "peerDependencies": { "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.0.3", - "@vitest/ui": "3.0.3", + "@vitest/browser": "3.0.8", + "@vitest/ui": "3.0.8", "happy-dom": "*", "jsdom": "*" }, @@ -15907,6 +17203,9 @@ "@edge-runtime/vm": { "optional": true }, + "@types/debug": { + "optional": true + }, "@types/node": { "optional": true }, @@ -15924,16 +17223,6 @@ } } }, - "node_modules/vitest/node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" - } - }, "node_modules/watchpack": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", @@ -15964,9 +17253,9 @@ "license": "BSD-2-Clause" }, "node_modules/webpack": { - "version": "5.97.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.97.1.tgz", - "integrity": "sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==", + "version": "5.98.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz", + "integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==", "dev": true, "license": "MIT", "dependencies": { @@ -15988,9 +17277,9 @@ "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", - "schema-utils": "^3.2.0", + "schema-utils": "^4.3.0", "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.10", + "terser-webpack-plugin": "^5.3.11", "watchpack": "^2.4.1", "webpack-sources": "^3.2.3" }, @@ -16037,6 +17326,54 @@ "dev": true, "license": "MIT" }, + "node_modules/webpack/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/webpack/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, "node_modules/webpack/node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -16061,6 +17398,56 @@ "node": ">=4.0" } }, + "node_modules/webpack/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/webpack/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", + "integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -16160,6 +17547,48 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -16212,9 +17641,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz", - "integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", + "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -16263,6 +17692,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", + "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zip-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", @@ -16276,6 +17718,16 @@ "engines": { "node": ">= 14" } + }, + "node_modules/zod": { + "version": "3.24.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", + "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/server/package.json b/server/package.json index 7f93d4e503..bc12bc67a0 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.125.1", + "version": "1.129.0", "description": "", "author": "", "private": true, @@ -18,9 +18,9 @@ "check": "tsc --noEmit", "check:code": "npm run format && npm run lint && npm run check", "check:all": "npm run check:code && npm run test:cov", - "test": "vitest", - "test:cov": "vitest --coverage", - "test:medium": "vitest --config vitest.config.medium.mjs", + "test": "vitest --config test/vitest.config.mjs", + "test:cov": "vitest --config test/vitest.config.mjs --coverage", + "test:medium": "vitest --config test/vitest.config.medium.mjs", "typeorm": "typeorm", "lifecycle": "node ./dist/utils/lifecycle.js", "typeorm:migrations:create": "typeorm migration:create", @@ -35,21 +35,20 @@ "email:dev": "email dev -p 3050 --dir src/emails" }, "dependencies": { - "@nestjs/bullmq": "^11.0.0", - "@nestjs/common": "^10.2.2", - "@nestjs/core": "^10.2.2", + "@nestjs/bullmq": "^11.0.1", + "@nestjs/common": "^11.0.4", + "@nestjs/core": "^11.0.4", "@nestjs/event-emitter": "^3.0.0", - "@nestjs/platform-express": "^10.2.2", - "@nestjs/platform-socket.io": "^10.2.2", + "@nestjs/platform-express": "^11.0.4", + "@nestjs/platform-socket.io": "^11.0.4", "@nestjs/schedule": "^5.0.0", - "@nestjs/swagger": "^8.0.0", - "@nestjs/typeorm": "^10.0.0", - "@nestjs/websockets": "^10.2.2", - "@opentelemetry/auto-instrumentations-node": "^0.55.0", + "@nestjs/swagger": "^11.0.2", + "@nestjs/websockets": "^11.0.4", + "@opentelemetry/auto-instrumentations-node": "^0.56.0", "@opentelemetry/context-async-hooks": "^1.24.0", "@opentelemetry/exporter-prometheus": "^0.57.0", "@opentelemetry/sdk-node": "^0.57.0", - "@react-email/components": "^0.0.32", + "@react-email/components": "^0.0.33", "@socket.io/redis-adapter": "^8.3.0", "archiver": "^7.0.0", "async-lock": "^1.4.0", @@ -58,7 +57,8 @@ "chokidar": "^3.5.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", - "cookie-parser": "^1.4.6", + "cookie": "^1.0.2", + "cookie-parser": "^1.4.7", "exiftool-vendored": "^28.3.1", "fast-glob": "^3.3.2", "fluent-ffmpeg": "^2.1.2", @@ -72,9 +72,9 @@ "kysely-postgres-js": "^2.0.0", "lodash": "^4.17.21", "luxon": "^3.4.2", - "nest-commander": "^3.11.1", - "nestjs-cls": "^4.3.0", - "nestjs-kysely": "^1.0.0", + "nest-commander": "^3.16.0", + "nestjs-cls": "^5.0.0", + "nestjs-kysely": "^1.1.0", "nestjs-otel": "^6.0.0", "nodemailer": "^6.9.13", "openid-client": "^5.4.3", @@ -85,38 +85,41 @@ "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "sanitize-filename": "^1.6.3", + "sanitize-html": "^2.14.0", "semver": "^7.6.2", "sharp": "^0.33.0", "sirv": "^3.0.0", "tailwindcss-preset-email": "^1.3.2", "thumbhash": "^0.1.1", "typeorm": "^0.3.17", - "ua-parser-js": "^1.0.35", + "ua-parser-js": "^2.0.0", "validator": "^13.12.0" }, "devDependencies": { "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.8.0", - "@nestjs/cli": "^10.1.16", - "@nestjs/schematics": "^10.0.2", - "@nestjs/testing": "^10.2.2", + "@nestjs/cli": "^11.0.2", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.4", "@swc/core": "^1.4.14", "@testcontainers/postgresql": "^10.2.1", + "@testcontainers/redis": "^10.18.0", "@types/archiver": "^6.0.0", "@types/async-lock": "^1.4.2", "@types/bcrypt": "^5.0.0", - "@types/cookie-parser": "^1.4.3", + "@types/cookie-parser": "^1.4.8", "@types/express": "^4.17.17", "@types/fluent-ffmpeg": "^2.1.21", "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^22.10.9", + "@types/node": "^22.13.9", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", "@types/pngjs": "^6.0.5", "@types/react": "^19.0.0", + "@types/sanitize-html": "^2.13.0", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.0", "@types/ua-parser-js": "^0.7.36", @@ -127,8 +130,8 @@ "eslint-config-prettier": "^10.0.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^56.0.1", - "globals": "^15.9.0", - "kysely-codegen": "^0.17.0", + "globals": "^16.0.0", + "kysely-codegen": "^0.18.0", "mock-fs": "^5.2.0", "node-addon-api": "^8.3.0", "pngjs": "^7.0.0", @@ -137,6 +140,7 @@ "rimraf": "^6.0.0", "source-map-support": "^0.5.21", "sql-formatter": "^15.0.0", + "testcontainers": "^10.18.0", "tsconfig-paths": "^4.2.0", "typescript": "^5.3.3", "unplugin-swc": "^1.4.5", @@ -145,6 +149,6 @@ "vitest": "^3.0.0" }, "volta": { - "node": "22.13.1" + "node": "22.14.0" } } diff --git a/server/src/app.module.ts b/server/src/app.module.ts index cd19972206..3cc0446306 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -1,32 +1,32 @@ import { BullModule } from '@nestjs/bullmq'; import { Inject, Module, OnModuleDestroy, OnModuleInit, ValidationPipe } from '@nestjs/common'; -import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE, ModuleRef } from '@nestjs/core'; +import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule'; -import { TypeOrmModule } from '@nestjs/typeorm'; +import { PostgresJSDialect } from 'kysely-postgres-js'; import { ClsModule } from 'nestjs-cls'; import { KyselyModule } from 'nestjs-kysely'; import { OpenTelemetryModule } from 'nestjs-otel'; +import postgres from 'postgres'; import { commands } from 'src/commands'; import { IWorker } from 'src/constants'; import { controllers } from 'src/controllers'; -import { entities } from 'src/entities'; import { ImmichWorker } from 'src/enum'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { IJobRepository } from 'src/interfaces/job.interface'; import { AuthGuard } from 'src/middleware/auth.guard'; import { ErrorInterceptor } from 'src/middleware/error.interceptor'; import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor'; import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter'; import { LoggingInterceptor } from 'src/middleware/logging.interceptor'; -import { providers, repositories } from 'src/repositories'; +import { repositories } from 'src/repositories'; import { ConfigRepository } from 'src/repositories/config.repository'; +import { EventRepository } from 'src/repositories/event.repository'; +import { JobRepository } from 'src/repositories/job.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository'; import { services } from 'src/services'; +import { AuthService } from 'src/services/auth.service'; import { CliService } from 'src/services/cli.service'; -import { DatabaseService } from 'src/services/database.service'; -const common = [...services, ...providers, ...repositories]; +const common = [...repositories, ...services, GlobalExceptionFilter]; const middleware = [ FileUploadInterceptor, @@ -45,40 +45,49 @@ const imports = [ BullModule.registerQueue(...bull.queues), ClsModule.forRoot(cls.config), OpenTelemetryModule.forRoot(otel), - TypeOrmModule.forRootAsync({ - inject: [ModuleRef], - useFactory: (moduleRef: ModuleRef) => { - return { - ...database.config.typeorm, - poolErrorHandler: (error) => { - moduleRef.get(DatabaseService, { strict: false }).handleConnectionError(error); - }, - }; + KyselyModule.forRoot({ + dialect: new PostgresJSDialect({ postgres: postgres(database.config.kysely) }), + log(event) { + if (event.level === 'error') { + console.error('Query failed :', { + durationMs: event.queryDurationMillis, + error: event.error, + sql: event.query.sql, + params: event.query.parameters, + }); + } }, }), - TypeOrmModule.forFeature(entities), - KyselyModule.forRoot(database.config.kysely), ]; class BaseModule implements OnModuleInit, OnModuleDestroy { constructor( @Inject(IWorker) private worker: ImmichWorker, logger: LoggingRepository, - @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, + private eventRepository: EventRepository, + private jobRepository: JobRepository, private telemetryRepository: TelemetryRepository, + private authService: AuthService, ) { logger.setAppName(this.worker); } async onModuleInit() { - this.telemetryRepository.setup({ repositories: [...providers.map(({ useClass }) => useClass), ...repositories] }); + this.telemetryRepository.setup({ repositories }); this.jobRepository.setup({ services }); if (this.worker === ImmichWorker.MICROSERVICES) { this.jobRepository.startWorkers(); } + this.eventRepository.setAuthFn(async (client) => + this.authService.authenticate({ + headers: client.request.headers, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: false, uri: '/api/socket.io' }, + }), + ); + this.eventRepository.setup({ services }); await this.eventRepository.emit('app.bootstrap'); } diff --git a/server/src/bin/sync-sql.ts b/server/src/bin/sync-sql.ts index c25e1c8a90..61c19c02fb 100644 --- a/server/src/bin/sync-sql.ts +++ b/server/src/bin/sync-sql.ts @@ -3,22 +3,23 @@ import { INestApplication } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { SchedulerRegistry } from '@nestjs/schedule'; import { Test } from '@nestjs/testing'; -import { TypeOrmModule } from '@nestjs/typeorm'; +import { ClassConstructor } from 'class-transformer'; +import { PostgresJSDialect } from 'kysely-postgres-js'; +import { ClsModule } from 'nestjs-cls'; import { KyselyModule } from 'nestjs-kysely'; import { OpenTelemetryModule } from 'nestjs-otel'; import { mkdir, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; +import postgres from 'postgres'; import { format } from 'sql-formatter'; import { GENERATE_SQL_KEY, GenerateSqlQueries } from 'src/decorators'; -import { entities } from 'src/entities'; -import { providers, repositories } from 'src/repositories'; +import { repositories } from 'src/repositories'; import { AccessRepository } from 'src/repositories/access.repository'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { AuthService } from 'src/services/auth.service'; -import { Logger } from 'typeorm'; -export class SqlLogger implements Logger { +export class SqlLogger { queries: string[] = []; errors: Array<{ error: string | Error; query: string }> = []; @@ -34,17 +35,11 @@ export class SqlLogger implements Logger { logQueryError(error: string | Error, query: string) { this.errors.push({ error, query }); } - - logQuerySlow() {} - logSchemaBuild() {} - logMigration() {} - log() {} } const reflector = new Reflector(); -type Repository = (typeof providers)[0]['useClass']; -type Provider = { provide: any; useClass: Repository }; +type Repository = ClassConstructor; type SqlGeneratorOptions = { targetDir: string }; class SqlGenerator { @@ -57,15 +52,11 @@ class SqlGenerator { async run() { try { await this.setup(); - const targets = [ - ...providers, - ...repositories.map((repository) => ({ provide: repository, useClass: repository as any })), - ]; - for (const repository of targets) { - if (repository.provide === LoggingRepository) { + for (const Repository of repositories) { + if (Repository === LoggingRepository) { continue; } - await this.process(repository); + await this.process(Repository); } await this.write(); this.stats(); @@ -79,12 +70,12 @@ class SqlGenerator { await mkdir(this.options.targetDir); process.env.DB_HOSTNAME = 'localhost'; - const { database, otel } = new ConfigRepository().getEnv(); + const { database, cls, otel } = new ConfigRepository().getEnv(); const moduleFixture = await Test.createTestingModule({ imports: [ KyselyModule.forRoot({ - ...database.config.kysely, + dialect: new PostgresJSDialect({ postgres: postgres(database.config.kysely) }), log: (event) => { if (event.level === 'query') { this.sqlLogger.logQuery(event.query.sql); @@ -94,28 +85,22 @@ class SqlGenerator { } }, }), - TypeOrmModule.forRoot({ - ...database.config.typeorm, - entities, - logging: ['query'], - logger: this.sqlLogger, - }), - TypeOrmModule.forFeature(entities), + ClsModule.forRoot(cls.config), OpenTelemetryModule.forRoot(otel), ], - providers: [...providers, ...repositories, AuthService, SchedulerRegistry], + providers: [...repositories, AuthService, SchedulerRegistry], }).compile(); this.app = await moduleFixture.createNestApplication().init(); } - async process({ provide: token, useClass: Repository }: Provider) { + async process(Repository: Repository) { if (!this.app) { throw new Error('Not initialized'); } const data: string[] = [`-- NOTE: This file is auto generated by ./sql-generator`]; - const instance = this.app.get(token); + const instance = this.app.get(Repository); // normal repositories data.push(...(await this.runTargets(instance, `${Repository.name}`))); diff --git a/server/src/config.ts b/server/src/config.ts index 7dd015c0fa..e7f3d4b8b6 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -5,14 +5,14 @@ import { CQMode, ImageFormat, LogLevel, + QueueName, ToneMapping, TranscodeHWAccel, TranscodePolicy, VideoCodec, VideoContainer, } from 'src/enum'; -import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface'; -import { ImageOptions } from 'src/types'; +import { ConcurrentQueueName, ImageOptions } from 'src/types'; export interface SystemConfig { backup: { diff --git a/server/src/constants.ts b/server/src/constants.ts index 050a7d06fa..3e946578ab 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -1,21 +1,27 @@ import { Duration } from 'luxon'; import { readFileSync } from 'node:fs'; import { SemVer } from 'semver'; -import { ExifOrientation } from 'src/enum'; +import { DatabaseExtension, ExifOrientation } from 'src/enum'; export const POSTGRES_VERSION_RANGE = '>=14.0.0'; export const VECTORS_VERSION_RANGE = '>=0.2 <0.4'; export const VECTOR_VERSION_RANGE = '>=0.5 <1'; -export const ASSET_FILE_CONFLICT_KEYS = ['assetId', 'type'] as const; -export const EXIF_CONFLICT_KEYS = ['assetId'] as const; -export const JOB_STATUS_CONFLICT_KEYS = ['assetId'] as const; - export const NEXT_RELEASE = 'NEXT_RELEASE'; export const LIFECYCLE_EXTENSION = 'x-immich-lifecycle'; export const DEPRECATED_IN_PREFIX = 'This property was deprecated in '; export const ADDED_IN_PREFIX = 'This property was added in '; +export const JOBS_ASSET_PAGINATION_SIZE = 1000; +export const JOBS_LIBRARY_PAGINATION_SIZE = 10_000; + +export const EXTENSION_NAMES: Record = { + cube: 'cube', + earthdistance: 'earthdistance', + vector: 'pgvector', + vectors: 'pgvecto.rs', +} as const; + export const SALT_ROUNDS = 10; export const IWorker = 'IWorker'; @@ -28,6 +34,11 @@ export const ONE_HOUR = Duration.fromObject({ hours: 1 }); export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || './upload'; +export const MACHINE_LEARNING_PING_TIMEOUT = Number(process.env.MACHINE_LEARNING_PING_TIMEOUT || 2000); +export const MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME = Number( + process.env.MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME || 30_000, +); + export const citiesFile = 'cities500.txt'; export const MOBILE_REDIRECT = 'app.immich:///oauth-callback'; diff --git a/server/src/controllers/api-key.controller.ts b/server/src/controllers/api-key.controller.ts index 4691ce05ef..08efd753cf 100644 --- a/server/src/controllers/api-key.controller.ts +++ b/server/src/controllers/api-key.controller.ts @@ -4,13 +4,13 @@ import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpda import { AuthDto } from 'src/dtos/auth.dto'; import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; -import { APIKeyService } from 'src/services/api-key.service'; +import { ApiKeyService } from 'src/services/api-key.service'; import { UUIDParamDto } from 'src/validation'; @ApiTags('API Keys') @Controller('api-keys') export class APIKeyController { - constructor(private service: APIKeyService) {} + constructor(private service: ApiKeyService) {} @Post() @Authenticated({ permission: Permission.API_KEY_CREATE }) diff --git a/server/src/controllers/asset-media.controller.ts b/server/src/controllers/asset-media.controller.ts index 553f1a261f..3d2845690d 100644 --- a/server/src/controllers/asset-media.controller.ts +++ b/server/src/controllers/asset-media.controller.ts @@ -14,7 +14,7 @@ import { UploadedFiles, UseInterceptors, } from '@nestjs/common'; -import { ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger'; +import { ApiBody, ApiConsumes, ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger'; import { NextFunction, Response } from 'express'; import { EndpointLifecycle } from 'src/decorators'; import { @@ -35,9 +35,10 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { ImmichHeader, RouteKey } from 'src/enum'; import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; -import { FileUploadInterceptor, UploadFiles, getFiles } from 'src/middleware/file-upload.interceptor'; +import { FileUploadInterceptor, getFiles } from 'src/middleware/file-upload.interceptor'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { AssetMediaService } from 'src/services/asset-media.service'; +import { UploadFiles } from 'src/types'; import { sendFile } from 'src/utils/file'; import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation'; @@ -94,6 +95,10 @@ export class AssetMediaController { @UseInterceptors(FileUploadInterceptor) @ApiConsumes('multipart/form-data') @EndpointLifecycle({ addedAt: 'v1.106.0' }) + @ApiOperation({ + summary: 'replaceAsset', + description: 'Replace the asset with new file, without changing its id', + }) @Authenticated({ sharedLink: true }) async replaceAsset( @Auth() auth: AuthDto, @@ -141,6 +146,10 @@ export class AssetMediaController { */ @Post('exist') @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'checkExistingAssets', + description: 'Checks if multiple assets exist on the server and returns all existing - used by background backup', + }) @Authenticated() checkExistingAssets( @Auth() auth: AuthDto, @@ -154,6 +163,10 @@ export class AssetMediaController { */ @Post('bulk-upload-check') @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'checkBulkUpload', + description: 'Checks if assets exist by checksums', + }) @Authenticated() checkBulkUpload( @Auth() auth: AuthDto, diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index 8a5b5fb0b6..9a7252a087 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -1,5 +1,5 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { EndpointLifecycle } from 'src/decorators'; import { AssetResponseDto, MemoryLaneResponseDto } from 'src/dtos/asset-response.dto'; import { @@ -41,6 +41,10 @@ export class AssetController { * Get all asset of a device that are in the database, ID only. */ @Get('/device/:deviceId') + @ApiOperation({ + summary: 'getAllUserAssetsByDeviceId', + description: 'Get all asset of a device that are in the database, ID only.', + }) @Authenticated() getAllUserAssetsByDeviceId(@Auth() auth: AuthDto, @Param() { deviceId }: DeviceIdDto) { return this.service.getUserAssetsByDeviceId(auth, deviceId); diff --git a/server/src/controllers/audit.controller.ts b/server/src/controllers/audit.controller.ts deleted file mode 100644 index 856a1cc755..0000000000 --- a/server/src/controllers/audit.controller.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Controller, Get, Query } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; -import { AuditDeletesDto, AuditDeletesResponseDto } from 'src/dtos/audit.dto'; -import { AuthDto } from 'src/dtos/auth.dto'; -import { Auth, Authenticated } from 'src/middleware/auth.guard'; -import { AuditService } from 'src/services/audit.service'; - -@ApiTags('Audit') -@Controller('audit') -export class AuditController { - constructor(private service: AuditService) {} - - @Get('deletes') - @Authenticated() - getAuditDeletes(@Auth() auth: AuthDto, @Query() dto: AuditDeletesDto): Promise { - return this.service.getDeletes(auth, dto); - } -} diff --git a/server/src/controllers/face.controller.ts b/server/src/controllers/face.controller.ts index 7d93bfd34d..d94cd532f7 100644 --- a/server/src/controllers/face.controller.ts +++ b/server/src/controllers/face.controller.ts @@ -1,7 +1,13 @@ -import { Body, Controller, Get, Param, Put, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetFaceResponseDto, FaceDto, PersonResponseDto } from 'src/dtos/person.dto'; +import { + AssetFaceCreateDto, + AssetFaceDeleteDto, + AssetFaceResponseDto, + FaceDto, + PersonResponseDto, +} from 'src/dtos/person.dto'; import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { PersonService } from 'src/services/person.service'; @@ -12,6 +18,12 @@ import { UUIDParamDto } from 'src/validation'; export class FaceController { constructor(private service: PersonService) {} + @Post() + @Authenticated({ permission: Permission.FACE_CREATE }) + createFace(@Auth() auth: AuthDto, @Body() dto: AssetFaceCreateDto) { + return this.service.createFace(auth, dto); + } + @Get() @Authenticated({ permission: Permission.FACE_READ }) getFaces(@Auth() auth: AuthDto, @Query() dto: FaceDto): Promise { @@ -27,4 +39,10 @@ export class FaceController { ): Promise { return this.service.reassignFacesById(auth, id, dto); } + + @Delete(':id') + @Authenticated({ permission: Permission.FACE_DELETE }) + deleteFace(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: AssetFaceDeleteDto) { + return this.service.deleteFace(auth, id, dto); + } } diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index f10bf601b4..c9d63f8bcd 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -4,7 +4,6 @@ import { APIKeyController } from 'src/controllers/api-key.controller'; import { AppController } from 'src/controllers/app.controller'; import { AssetMediaController } from 'src/controllers/asset-media.controller'; import { AssetController } from 'src/controllers/asset.controller'; -import { AuditController } from 'src/controllers/audit.controller'; import { AuthController } from 'src/controllers/auth.controller'; import { DownloadController } from 'src/controllers/download.controller'; import { DuplicateController } from 'src/controllers/duplicate.controller'; @@ -40,7 +39,6 @@ export const controllers = [ AppController, AssetController, AssetMediaController, - AuditController, AuthController, DownloadController, DuplicateController, diff --git a/server/src/controllers/memory.controller.ts b/server/src/controllers/memory.controller.ts index 710ca9f2f8..1f848ad705 100644 --- a/server/src/controllers/memory.controller.ts +++ b/server/src/controllers/memory.controller.ts @@ -1,8 +1,8 @@ -import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { MemoryCreateDto, MemoryResponseDto, MemoryUpdateDto } from 'src/dtos/memory.dto'; +import { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, MemoryUpdateDto } from 'src/dtos/memory.dto'; import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { MemoryService } from 'src/services/memory.service'; @@ -15,8 +15,8 @@ export class MemoryController { @Get() @Authenticated({ permission: Permission.MEMORY_READ }) - searchMemories(@Auth() auth: AuthDto): Promise { - return this.service.search(auth); + searchMemories(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise { + return this.service.search(auth, dto); } @Post() diff --git a/server/src/controllers/shared-link.controller.ts b/server/src/controllers/shared-link.controller.ts index 59f81068d8..ca978f03da 100644 --- a/server/src/controllers/shared-link.controller.ts +++ b/server/src/controllers/shared-link.controller.ts @@ -9,6 +9,7 @@ import { SharedLinkEditDto, SharedLinkPasswordDto, SharedLinkResponseDto, + SharedLinkSearchDto, } from 'src/dtos/shared-link.dto'; import { ImmichCookie, Permission } from 'src/enum'; import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard'; @@ -24,8 +25,8 @@ export class SharedLinkController { @Get() @Authenticated({ permission: Permission.SHARED_LINK_READ }) - getAllSharedLinks(@Auth() auth: AuthDto): Promise { - return this.service.getAll(auth); + getAllSharedLinks(@Auth() auth: AuthDto, @Query() dto: SharedLinkSearchDto): Promise { + return this.service.getAll(auth, dto); } @Get('me') diff --git a/server/src/controllers/sync.controller.ts b/server/src/controllers/sync.controller.ts index 4d970a7102..0945810be7 100644 --- a/server/src/controllers/sync.controller.ts +++ b/server/src/controllers/sync.controller.ts @@ -1,15 +1,28 @@ -import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Header, HttpCode, HttpStatus, Post, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { Response } from 'express'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetDeltaSyncDto, AssetDeltaSyncResponseDto, AssetFullSyncDto } from 'src/dtos/sync.dto'; +import { + AssetDeltaSyncDto, + AssetDeltaSyncResponseDto, + AssetFullSyncDto, + SyncAckDeleteDto, + SyncAckDto, + SyncAckSetDto, + SyncStreamDto, +} from 'src/dtos/sync.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter'; import { SyncService } from 'src/services/sync.service'; @ApiTags('Sync') @Controller('sync') export class SyncController { - constructor(private service: SyncService) {} + constructor( + private service: SyncService, + private errorService: GlobalExceptionFilter, + ) {} @Post('full-sync') @HttpCode(HttpStatus.OK) @@ -24,4 +37,37 @@ export class SyncController { getDeltaSync(@Auth() auth: AuthDto, @Body() dto: AssetDeltaSyncDto): Promise { return this.service.getDeltaSync(auth, dto); } + + @Post('stream') + @Header('Content-Type', 'application/jsonlines+json') + @HttpCode(HttpStatus.OK) + @Authenticated() + async getSyncStream(@Auth() auth: AuthDto, @Res() res: Response, @Body() dto: SyncStreamDto) { + try { + await this.service.stream(auth, res, dto); + } catch (error: Error | any) { + res.setHeader('Content-Type', 'application/json'); + this.errorService.handleError(res, error); + } + } + + @Get('ack') + @Authenticated() + getSyncAck(@Auth() auth: AuthDto): Promise { + return this.service.getAcks(auth); + } + + @Post('ack') + @HttpCode(HttpStatus.NO_CONTENT) + @Authenticated() + sendSyncAck(@Auth() auth: AuthDto, @Body() dto: SyncAckSetDto) { + return this.service.setAcks(auth, dto); + } + + @Delete('ack') + @HttpCode(HttpStatus.NO_CONTENT) + @Authenticated() + deleteSyncAck(@Auth() auth: AuthDto, @Body() dto: SyncAckDeleteDto) { + return this.service.deleteAcks(auth, dto); + } } diff --git a/server/src/controllers/user-admin.controller.ts b/server/src/controllers/user-admin.controller.ts index d44115be2f..4dfeae949a 100644 --- a/server/src/controllers/user-admin.controller.ts +++ b/server/src/controllers/user-admin.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; @@ -75,6 +75,7 @@ export class UserAdminController { @Post(':id/restore') @Authenticated({ permission: Permission.ADMIN_USER_DELETE, admin: true }) + @HttpCode(HttpStatus.OK) restoreUserAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.restore(auth, id); } diff --git a/server/src/controllers/user.controller.ts b/server/src/controllers/user.controller.ts index 9dbaa00d81..f1bdf160d3 100644 --- a/server/src/controllers/user.controller.ts +++ b/server/src/controllers/user.controller.ts @@ -44,7 +44,7 @@ export class UserController { @Get('me') @Authenticated() - getMyUser(@Auth() auth: AuthDto): UserAdminResponseDto { + getMyUser(@Auth() auth: AuthDto): Promise { return this.service.getMe(auth); } @@ -56,7 +56,7 @@ export class UserController { @Get('me/preferences') @Authenticated() - getMyPreferences(@Auth() auth: AuthDto): UserPreferencesResponseDto { + getMyPreferences(@Auth() auth: AuthDto): Promise { return this.service.getMyPreferences(auth); } @@ -71,7 +71,7 @@ export class UserController { @Get('me/license') @Authenticated() - getUserLicense(@Auth() auth: AuthDto): LicenseResponseDto { + getUserLicense(@Auth() auth: AuthDto): Promise { return this.service.getLicense(auth); } diff --git a/server/src/cores/storage.core.spec.ts b/server/src/cores/storage.core.spec.ts index a663673306..7bb2cdb1be 100644 --- a/server/src/cores/storage.core.spec.ts +++ b/server/src/cores/storage.core.spec.ts @@ -5,6 +5,7 @@ vitest.mock('src/constants', () => ({ APP_MEDIA_LOCATION: '/photos', ADDED_IN_PREFIX: 'This property was added in ', DEPRECATED_IN_PREFIX: 'This property was deprecated in ', + IWorker: 'IWorker', })); describe('StorageCore', () => { diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index 50b07981a6..3160331dd4 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -4,13 +4,14 @@ import { APP_MEDIA_LOCATION } from 'src/constants'; import { AssetEntity } from 'src/entities/asset.entity'; import { PersonEntity } from 'src/entities/person.entity'; import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IMoveRepository } from 'src/interfaces/move.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { IConfigRepository, ILoggingRepository } from 'src/types'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { ConfigRepository } from 'src/repositories/config.repository'; +import { CryptoRepository } from 'src/repositories/crypto.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { MoveRepository } from 'src/repositories/move.repository'; +import { PersonRepository } from 'src/repositories/person.repository'; +import { StorageRepository } from 'src/repositories/storage.repository'; +import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { getAssetFiles } from 'src/utils/asset.util'; import { getConfig } from 'src/utils/config'; @@ -32,25 +33,25 @@ let instance: StorageCore | null; export class StorageCore { private constructor( - private assetRepository: IAssetRepository, - private configRepository: IConfigRepository, - private cryptoRepository: ICryptoRepository, - private moveRepository: IMoveRepository, - private personRepository: IPersonRepository, - private storageRepository: IStorageRepository, - private systemMetadataRepository: ISystemMetadataRepository, - private logger: ILoggingRepository, + private assetRepository: AssetRepository, + private configRepository: ConfigRepository, + private cryptoRepository: CryptoRepository, + private moveRepository: MoveRepository, + private personRepository: PersonRepository, + private storageRepository: StorageRepository, + private systemMetadataRepository: SystemMetadataRepository, + private logger: LoggingRepository, ) {} static create( - assetRepository: IAssetRepository, - configRepository: IConfigRepository, - cryptoRepository: ICryptoRepository, - moveRepository: IMoveRepository, - personRepository: IPersonRepository, - storageRepository: IStorageRepository, - systemMetadataRepository: ISystemMetadataRepository, - logger: ILoggingRepository, + assetRepository: AssetRepository, + configRepository: ConfigRepository, + cryptoRepository: CryptoRepository, + moveRepository: MoveRepository, + personRepository: PersonRepository, + storageRepository: StorageRepository, + systemMetadataRepository: SystemMetadataRepository, + logger: LoggingRepository, ) { if (!instance) { instance = new StorageCore( diff --git a/server/src/database.ts b/server/src/database.ts index fce9ede561..e899200579 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -1,3 +1,162 @@ +import { sql } from 'kysely'; +import { AssetStatus, AssetType, Permission } from 'src/enum'; + +export type AuthUser = { + id: string; + isAdmin: boolean; + name: string; + email: string; + quotaUsageInBytes: number; + quotaSizeInBytes: number | null; +}; + +export type Library = { + id: string; + ownerId: string; + createdAt: Date; + updatedAt: Date; + updateId: string; + name: string; + importPaths: string[]; + exclusionPatterns: string[]; + deletedAt: Date | null; + refreshedAt: Date | null; + assets?: Asset[]; +}; + +export type AuthApiKey = { + id: string; + permissions: Permission[]; +}; + +export type ApiKey = { + id: string; + name: string; + userId: string; + createdAt: Date; + updatedAt: Date; + permissions: Permission[]; +}; + +export type User = { + id: string; + name: string; + email: string; + profileImagePath: string; + profileChangedAt: Date; +}; + +export type Asset = { + createdAt: Date; + updatedAt: Date; + deletedAt: Date | null; + id: string; + updateId: string; + status: AssetStatus; + checksum: Buffer; + deviceAssetId: string; + deviceId: string; + duplicateId: string | null; + duration: string | null; + encodedVideoPath: string | null; + fileCreatedAt: Date | null; + fileModifiedAt: Date | null; + isArchived: boolean; + isExternal: boolean; + isFavorite: boolean; + isOffline: boolean; + isVisible: boolean; + libraryId: string | null; + livePhotoVideoId: string | null; + localDateTime: Date | null; + originalFileName: string; + originalPath: string; + ownerId: string; + sidecarPath: string | null; + stackId: string | null; + thumbhash: Buffer | null; + type: AssetType; +}; + +export type AuthSharedLink = { + id: string; + expiresAt: Date | null; + userId: string; + showExif: boolean; + allowUpload: boolean; + allowDownload: boolean; + password: string | null; +}; + +export type AuthSession = { + id: string; +}; + export const columns = { + ackEpoch: (columnName: 'createdAt' | 'updatedAt' | 'deletedAt') => + sql.raw(`extract(epoch from "${columnName}")::text`).as('ackEpoch'), + authUser: [ + 'users.id', + 'users.name', + 'users.email', + 'users.isAdmin', + 'users.quotaUsageInBytes', + 'users.quotaSizeInBytes', + ], + authApiKey: ['api_keys.id', 'api_keys.permissions'], + authSession: ['sessions.id', 'sessions.updatedAt'], + authSharedLink: [ + 'shared_links.id', + 'shared_links.userId', + 'shared_links.expiresAt', + 'shared_links.showExif', + 'shared_links.allowUpload', + 'shared_links.allowDownload', + 'shared_links.password', + ], userDto: ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'], + tagDto: ['id', 'value', 'createdAt', 'updatedAt', 'color', 'parentId'], + apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'], + syncAsset: [ + 'id', + 'ownerId', + 'thumbhash', + 'checksum', + 'fileCreatedAt', + 'fileModifiedAt', + 'localDateTime', + 'type', + 'deletedAt', + 'isFavorite', + 'isVisible', + 'updateId', + ], + syncAssetExif: [ + 'exif.assetId', + 'exif.description', + 'exif.exifImageWidth', + 'exif.exifImageHeight', + 'exif.fileSizeInByte', + 'exif.orientation', + 'exif.dateTimeOriginal', + 'exif.modifyDate', + 'exif.timeZone', + 'exif.latitude', + 'exif.longitude', + 'exif.projectionType', + 'exif.city', + 'exif.state', + 'exif.country', + 'exif.make', + 'exif.model', + 'exif.lensModel', + 'exif.fNumber', + 'exif.focalLength', + 'exif.iso', + 'exif.exposureTime', + 'exif.profileDescription', + 'exif.rating', + 'exif.fps', + 'exif.updateId', + ], } as const; diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 6242914bee..85aade2c9b 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -3,23 +3,20 @@ * Please do not edit it manually. */ -import type { ColumnType } from "kysely"; +import type { ColumnType } from 'kysely'; +import { OnThisDayData } from 'src/entities/memory.entity'; +import { AssetType, MemoryType, Permission, SyncEntityType } from 'src/enum'; -export type ArrayType = ArrayTypeImpl extends (infer U)[] - ? U[] - : ArrayTypeImpl; +export type ArrayType = ArrayTypeImpl extends (infer U)[] ? U[] : ArrayTypeImpl; -export type ArrayTypeImpl = T extends ColumnType - ? ColumnType - : T[]; +export type ArrayTypeImpl = T extends ColumnType ? ColumnType : T[]; -export type AssetsStatusEnum = "active" | "deleted" | "trashed"; +export type AssetsStatusEnum = 'active' | 'deleted' | 'trashed'; -export type Generated = T extends ColumnType - ? ColumnType - : ColumnType; +export type Generated = + T extends ColumnType ? ColumnType : ColumnType; -export type Int8 = ColumnType; +export type Int8 = ColumnType; export type Json = JsonValue; @@ -33,7 +30,7 @@ export type JsonPrimitive = boolean | number | string | null; export type JsonValue = JsonArray | JsonObject | JsonPrimitive; -export type Sourcetype = "exif" | "machine-learning"; +export type Sourcetype = 'exif' | 'machine-learning' | 'manual'; export type Timestamp = ColumnType; @@ -45,6 +42,7 @@ export interface Activity { id: Generated; isLiked: Generated; updatedAt: Generated; + updateId: Generated; userId: string; } @@ -62,6 +60,7 @@ export interface Albums { order: Generated; ownerId: string; updatedAt: Generated; + updateId: Generated; } export interface AlbumsAssetsAssets { @@ -81,8 +80,9 @@ export interface ApiKeys { id: Generated; key: string; name: string; - permissions: string[]; + permissions: Permission[]; updatedAt: Generated; + updateId: Generated; userId: string; } @@ -92,6 +92,7 @@ export interface AssetFaces { boundingBoxX2: Generated; boundingBoxY1: Generated; boundingBoxY2: Generated; + deletedAt: Timestamp | null; id: Generated; imageHeight: Generated; imageWidth: Generated; @@ -106,6 +107,7 @@ export interface AssetFiles { path: string; type: string; updatedAt: Generated; + updateId: Generated; } export interface AssetJobStatus { @@ -117,6 +119,13 @@ export interface AssetJobStatus { thumbnailAt: Timestamp | null; } +export interface AssetsAudit { + deletedAt: Generated; + id: Generated; + assetId: string; + ownerId: string; +} + export interface Assets { checksum: Buffer; createdAt: Generated; @@ -126,8 +135,8 @@ export interface Assets { duplicateId: string | null; duration: string | null; encodedVideoPath: Generated; - fileCreatedAt: Timestamp; - fileModifiedAt: Timestamp; + fileCreatedAt: Timestamp | null; + fileModifiedAt: Timestamp | null; id: Generated; isArchived: Generated; isExternal: Generated; @@ -136,7 +145,7 @@ export interface Assets { isVisible: Generated; libraryId: string | null; livePhotoVideoId: string | null; - localDateTime: Timestamp; + localDateTime: Timestamp | null; originalFileName: string; originalPath: string; ownerId: string; @@ -144,8 +153,9 @@ export interface Assets { stackId: string | null; status: Generated; thumbhash: Buffer | null; - type: string; + type: AssetType; updatedAt: Generated; + updateId: Generated; } export interface AssetStack { @@ -165,6 +175,8 @@ export interface Audit { export interface Exif { assetId: string; + updateId: Generated; + updatedAt: Generated; autoStackId: string | null; bitsPerSample: number | null; city: string | null; @@ -224,19 +236,23 @@ export interface Libraries { ownerId: string; refreshedAt: Timestamp | null; updatedAt: Generated; + updateId: Generated; } export interface Memories { createdAt: Generated; - data: Json; + data: OnThisDayData; deletedAt: Timestamp | null; + hideAt: Timestamp | null; id: Generated; isSaved: Generated; memoryAt: Timestamp; ownerId: string; seenAt: Timestamp | null; - type: string; + showAt: Timestamp | null; + type: MemoryType; updatedAt: Generated; + updateId: Generated; } export interface MemoriesAssetsAssets { @@ -266,24 +282,35 @@ export interface NaturalearthCountries { type: string; } +export interface PartnersAudit { + deletedAt: Generated; + id: Generated; + sharedById: string; + sharedWithId: string; +} + export interface Partners { createdAt: Generated; inTimeline: Generated; sharedById: string; sharedWithId: string; updatedAt: Generated; + updateId: Generated; } export interface Person { birthDate: Timestamp | null; + color: string | null; createdAt: Generated; faceAssetId: string | null; id: Generated; + isFavorite: Generated; isHidden: Generated; name: Generated; ownerId: string; thumbnailPath: Generated; updatedAt: Generated; + updateId: Generated; } export interface Sessions { @@ -293,9 +320,19 @@ export interface Sessions { id: Generated; token: string; updatedAt: Generated; + updateId: Generated; userId: string; } +export interface SessionSyncCheckpoints { + ack: string; + createdAt: Generated; + sessionId: string; + type: SyncEntityType; + updatedAt: Generated; + updateId: Generated; +} + export interface SharedLinkAsset { assetsId: string; sharedLinksId: string; @@ -348,6 +385,7 @@ export interface Tags { id: Generated; parentId: string | null; updatedAt: Generated; + updateId: Generated; userId: string; value: string; } @@ -357,6 +395,15 @@ export interface TagsClosure { id_descendant: string; } +export interface TypeormMetadata { + database: string | null; + name: string | null; + schema: string | null; + table: string | null; + type: string; + value: string | null; +} + export interface UserMetadata { key: string; userId: string; @@ -380,6 +427,13 @@ export interface Users { status: Generated; storageLabel: string | null; updatedAt: Generated; + updateId: Generated; +} + +export interface UsersAudit { + id: Generated; + userId: string; + deletedAt: Generated; } export interface VectorsPgVectorIndexStat { @@ -414,6 +468,7 @@ export interface DB { asset_job_status: AssetJobStatus; asset_stack: AssetStack; assets: Assets; + assets_audit: AssetsAudit; audit: Audit; exif: Exif; face_search: FaceSearch; @@ -424,9 +479,11 @@ export interface DB { migrations: Migrations; move_history: MoveHistory; naturalearth_countries: NaturalearthCountries; + partners_audit: PartnersAudit; partners: Partners; person: Person; sessions: Sessions; + session_sync_checkpoints: SessionSyncCheckpoints; shared_link__asset: SharedLinkAsset; shared_links: SharedLinks; smart_search: SmartSearch; @@ -436,8 +493,10 @@ export interface DB { tag_asset: TagAsset; tags: Tags; tags_closure: TagsClosure; + typeorm_metadata: TypeormMetadata; user_metadata: UserMetadata; users: Users; - "vectors.pg_vector_index_stat": VectorsPgVectorIndexStat; + users_audit: UsersAudit; + 'vectors.pg_vector_index_stat': VectorsPgVectorIndexStat; version_history: VersionHistory; } diff --git a/server/src/decorators.ts b/server/src/decorators.ts index bb037ee097..56efdd1c08 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -2,9 +2,8 @@ import { SetMetadata, applyDecorators } from '@nestjs/common'; import { ApiExtension, ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger'; import _ from 'lodash'; import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants'; -import { ImmichWorker, MetadataKey } from 'src/enum'; -import { EmitEvent } from 'src/interfaces/event.interface'; -import { JobName, QueueName } from 'src/interfaces/job.interface'; +import { ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum'; +import { EmitEvent } from 'src/repositories/event.repository'; import { setUnion } from 'src/utils/set'; // PostgreSQL uses a 16-bit integer to indicate the number of bound parameters. This means that the diff --git a/server/src/dtos/album-response.dto.spec.ts b/server/src/dtos/album-response.dto.spec.ts index 2a6d59abf3..dd8642598f 100644 --- a/server/src/dtos/album-response.dto.spec.ts +++ b/server/src/dtos/album-response.dto.spec.ts @@ -4,8 +4,8 @@ import { albumStub } from 'test/fixtures/album.stub'; describe('mapAlbum', () => { it('should set start and end dates', () => { const dto = mapAlbum(albumStub.twoAssets, false); - expect(dto.startDate).toEqual(new Date('2023-02-22T05:06:29.716Z')); - expect(dto.endDate).toEqual(new Date('2023-02-23T05:06:29.716Z')); + expect(dto.startDate).toEqual(new Date('2020-12-31T23:59:00.000Z')); + expect(dto.endDate).toEqual(new Date('2025-01-01T01:02:03.456Z')); }); it('should not set start and end dates for empty assets', () => { diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index 2f99b958c4..14db0ab1e8 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -7,7 +7,6 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { AlbumEntity } from 'src/entities/album.entity'; import { AlbumUserRole, AssetOrder } from 'src/enum'; -import { getAssetDateTime } from 'src/utils/date-time'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; export class AlbumInfoDto { @@ -165,8 +164,8 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDt const hasSharedLink = entity.sharedLinks?.length > 0; const hasSharedUser = sharedUsers.length > 0; - let startDate = getAssetDateTime(assets.at(0)); - let endDate = getAssetDateTime(assets.at(-1)); + let startDate = assets.at(0)?.localDateTime; + let endDate = assets.at(-1)?.localDateTime; // Swap dates if start date is greater than end date. if (startDate && endDate && startDate > endDate) { [startDate, endDate] = [endDate, startDate]; diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 0658567912..b12a4378fe 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -102,7 +102,7 @@ const mapStack = (entity: AssetEntity) => { }; // if an asset is jsonified in the DB before being returned, its buffer fields will be hex-encoded strings -const hexOrBufferToBase64 = (encoded: string | Buffer) => { +export const hexOrBufferToBase64 = (encoded: string | Buffer) => { if (typeof encoded === 'string') { return Buffer.from(encoded.slice(2), 'hex').toString('base64'); } @@ -118,7 +118,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As id: entity.id, type: entity.type, originalMimeType: mimeTypes.lookup(entity.originalFileName), - thumbhash: entity.thumbhash?.toString('base64') ?? null, + thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null, localDateTime: entity.localDateTime, duration: entity.duration ?? '0:00:00.00000', livePhotoVideoId: entity.livePhotoVideoId, diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 42d6d7d745..32b14055d5 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -15,7 +15,7 @@ import { } from 'class-validator'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AssetType } from 'src/enum'; -import { AssetStats } from 'src/interfaces/asset.interface'; +import { AssetStats } from 'src/repositories/asset.repository'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; export class DeviceIdDto { @@ -52,7 +52,7 @@ export class UpdateAssetBase { @Optional() @IsInt() @Max(5) - @Min(0) + @Min(-1) rating?: number; } diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index d6b73f584a..334b7a49b5 100644 --- a/server/src/dtos/auth.dto.ts +++ b/server/src/dtos/auth.dto.ts @@ -1,11 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; -import { SessionEntity } from 'src/entities/session.entity'; -import { SharedLinkEntity } from 'src/entities/shared-link.entity'; +import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser } from 'src/database'; import { UserEntity } from 'src/entities/user.entity'; import { ImmichCookie } from 'src/enum'; -import { AuthApiKey } from 'src/types'; import { toEmail } from 'src/validation'; export type CookieResponse = { @@ -14,11 +12,11 @@ export type CookieResponse = { }; export class AuthDto { - user!: UserEntity; + user!: AuthUser; apiKey?: AuthApiKey; - sharedLink?: SharedLinkEntity; - session?: SessionEntity; + sharedLink?: AuthSharedLink; + session?: AuthSession; } export class LoginCredentialDto { diff --git a/server/src/dtos/job.dto.ts b/server/src/dtos/job.dto.ts index 31612bd8a4..ce6aad4c06 100644 --- a/server/src/dtos/job.dto.ts +++ b/server/src/dtos/job.dto.ts @@ -1,7 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsNotEmpty } from 'class-validator'; -import { ManualJobName } from 'src/enum'; -import { JobCommand, QueueName } from 'src/interfaces/job.interface'; +import { JobCommand, ManualJobName, QueueName } from 'src/enum'; import { ValidateBoolean } from 'src/validation'; export class JobIdParamDto { diff --git a/server/src/dtos/library.dto.ts b/server/src/dtos/library.dto.ts index 7fb363dd9a..a0aaace13d 100644 --- a/server/src/dtos/library.dto.ts +++ b/server/src/dtos/library.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { ArrayMaxSize, ArrayUnique, IsNotEmpty, IsString } from 'class-validator'; -import { LibraryEntity } from 'src/entities/library.entity'; +import { Library } from 'src/database'; import { Optional, ValidateUUID } from 'src/validation'; export class CreateLibraryDto { @@ -120,7 +120,7 @@ export class LibraryStatsResponseDto { usage = 0; } -export function mapLibrary(entity: LibraryEntity): LibraryResponseDto { +export function mapLibrary(entity: Library): LibraryResponseDto { let assetCount = 0; if (entity.assets) { assetCount = entity.assets.length; diff --git a/server/src/dtos/memory.dto.ts b/server/src/dtos/memory.dto.ts index 194bb8ac38..9eef78d4d0 100644 --- a/server/src/dtos/memory.dto.ts +++ b/server/src/dtos/memory.dto.ts @@ -5,7 +5,7 @@ import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { MemoryType } from 'src/enum'; import { MemoryItem } from 'src/types'; -import { ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; +import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; class MemoryBaseDto { @ValidateBoolean({ optional: true }) @@ -15,6 +15,22 @@ class MemoryBaseDto { seenAt?: Date; } +export class MemorySearchDto { + @Optional() + @IsEnum(MemoryType) + @ApiProperty({ enum: MemoryType, enumName: 'MemoryType' }) + type?: MemoryType; + + @ValidateDate({ optional: true }) + for?: Date; + + @ValidateBoolean({ optional: true }) + isTrashed?: boolean; + + @ValidateBoolean({ optional: true }) + isSaved?: boolean; +} + class OnThisDayDto { @IsInt() @IsPositive() @@ -62,6 +78,8 @@ export class MemoryResponseDto { deletedAt?: Date; memoryAt!: Date; seenAt?: Date; + showAt?: Date; + hideAt?: Date; ownerId!: string; @ApiProperty({ enumName: 'MemoryType', enum: MemoryType }) type!: MemoryType; @@ -78,6 +96,8 @@ export const mapMemory = (entity: MemoryItem): MemoryResponseDto => { deletedAt: entity.deletedAt ?? undefined, memoryAt: entity.memoryAt, seenAt: entity.seenAt ?? undefined, + showAt: entity.showAt ?? undefined, + hideAt: entity.hideAt ?? undefined, ownerId: entity.ownerId, type: entity.type as MemoryType, data: entity.data as unknown as MemoryData, diff --git a/server/src/dtos/partner.dto.ts b/server/src/dtos/partner.dto.ts index 38573998d6..9d86415dc3 100644 --- a/server/src/dtos/partner.dto.ts +++ b/server/src/dtos/partner.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsNotEmpty } from 'class-validator'; import { UserResponseDto } from 'src/dtos/user.dto'; -import { PartnerDirection } from 'src/interfaces/partner.interface'; +import { PartnerDirection } from 'src/repositories/partner.repository'; export class UpdatePartnerDto { @IsNotEmpty() diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index 047ef600b8..49f3416b9a 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -1,13 +1,21 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsArray, IsInt, IsNotEmpty, IsString, Max, Min, ValidateNested } from 'class-validator'; +import { IsArray, IsInt, IsNotEmpty, IsNumber, IsString, Max, Min, ValidateNested } from 'class-validator'; import { DateTime } from 'luxon'; import { PropertyLifecycle } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { PersonEntity } from 'src/entities/person.entity'; import { SourceType } from 'src/enum'; -import { IsDateStringFormat, MaxDateString, Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; +import { asDateString } from 'src/utils/date'; +import { + IsDateStringFormat, + MaxDateString, + Optional, + ValidateBoolean, + ValidateHexColor, + ValidateUUID, +} from 'src/validation'; export class PersonCreateDto { /** @@ -25,13 +33,20 @@ export class PersonCreateDto { @MaxDateString(() => DateTime.now(), { message: 'Birth date cannot be in the future' }) @IsDateStringFormat('yyyy-MM-dd') @Optional({ nullable: true }) - birthDate?: string | null; + birthDate?: Date | null; /** * Person visibility */ @ValidateBoolean({ optional: true }) isHidden?: boolean; + + @ValidateBoolean({ optional: true }) + isFavorite?: boolean; + + @Optional({ emptyToNull: true, nullable: true }) + @ValidateHexColor() + color?: string | null; } export class PersonUpdateDto extends PersonCreateDto { @@ -97,6 +112,10 @@ export class PersonResponseDto { isHidden!: boolean; @PropertyLifecycle({ addedAt: 'v1.107.0' }) updatedAt?: Date; + @PropertyLifecycle({ addedAt: 'v1.126.0' }) + isFavorite?: boolean; + @PropertyLifecycle({ addedAt: 'v1.126.0' }) + color?: string; } export class PersonWithFacesResponseDto extends PersonResponseDto { @@ -146,6 +165,43 @@ export class AssetFaceUpdateItem { assetId!: string; } +export class AssetFaceCreateDto extends AssetFaceUpdateItem { + @ApiProperty({ type: 'integer' }) + @IsNotEmpty() + @IsNumber() + imageWidth!: number; + + @ApiProperty({ type: 'integer' }) + @IsNotEmpty() + @IsNumber() + imageHeight!: number; + + @ApiProperty({ type: 'integer' }) + @IsNotEmpty() + @IsNumber() + x!: number; + + @ApiProperty({ type: 'integer' }) + @IsNotEmpty() + @IsNumber() + y!: number; + + @ApiProperty({ type: 'integer' }) + @IsNotEmpty() + @IsNumber() + width!: number; + + @ApiProperty({ type: 'integer' }) + @IsNotEmpty() + @IsNumber() + height!: number; +} + +export class AssetFaceDeleteDto { + @IsNotEmpty() + force!: boolean; +} + export class PersonStatisticsResponseDto { @ApiProperty({ type: 'integer' }) assets!: number; @@ -167,9 +223,11 @@ export function mapPerson(person: PersonEntity): PersonResponseDto { return { id: person.id, name: person.name, - birthDate: person.birthDate, + birthDate: asDateString(person.birthDate), thumbnailPath: person.thumbnailPath, isHidden: person.isHidden, + isFavorite: person.isFavorite, + color: person.color ?? undefined, updatedAt: person.updatedAt, }; } diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index f3f45af44d..3589331c78 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -111,6 +111,15 @@ class BaseSearchDto { @ValidateUUID({ each: true, optional: true }) personIds?: string[]; + + @ValidateUUID({ each: true, optional: true }) + tagIds?: string[]; + + @Optional() + @IsInt() + @Max(5) + @Min(-1) + rating?: number; } export class RandomSearchDto extends BaseSearchDto { @@ -130,6 +139,11 @@ export class MetadataSearchDto extends RandomSearchDto { @Optional() deviceAssetId?: string; + @IsString() + @IsNotEmpty() + @Optional() + description?: string; + @IsString() @IsNotEmpty() @Optional() diff --git a/server/src/dtos/session.dto.ts b/server/src/dtos/session.dto.ts index d96d7819ad..dab1bf62b5 100644 --- a/server/src/dtos/session.dto.ts +++ b/server/src/dtos/session.dto.ts @@ -1,4 +1,4 @@ -import { SessionEntity } from 'src/entities/session.entity'; +import { SessionItem } from 'src/types'; export class SessionResponseDto { id!: string; @@ -9,7 +9,7 @@ export class SessionResponseDto { deviceOS!: string; } -export const mapSession = (entity: SessionEntity, currentId?: string): SessionResponseDto => ({ +export const mapSession = (entity: SessionItem, currentId?: string): SessionResponseDto => ({ id: entity.id, createdAt: entity.createdAt.toISOString(), updatedAt: entity.updatedAt.toISOString(), diff --git a/server/src/dtos/shared-link.dto.ts b/server/src/dtos/shared-link.dto.ts index b97791db58..e3f8c72e19 100644 --- a/server/src/dtos/shared-link.dto.ts +++ b/server/src/dtos/shared-link.dto.ts @@ -7,6 +7,11 @@ import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SharedLinkType } from 'src/enum'; import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; +export class SharedLinkSearchDto { + @ValidateUUID({ optional: true }) + albumId?: string; +} + export class SharedLinkCreateDto { @IsEnum(SharedLinkType) @ApiProperty({ enum: SharedLinkType, enumName: 'SharedLinkType' }) diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 820de8d6c3..a035f8ecb9 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -1,7 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsInt, IsPositive } from 'class-validator'; +import { IsEnum, IsInt, IsPositive, IsString } from 'class-validator'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; -import { ValidateDate, ValidateUUID } from 'src/validation'; +import { AssetType, SyncEntityType, SyncRequestType } from 'src/enum'; +import { Optional, ValidateDate, ValidateUUID } from 'src/validation'; export class AssetFullSyncDto { @ValidateUUID({ optional: true }) @@ -32,3 +33,131 @@ export class AssetDeltaSyncResponseDto { upserted!: AssetResponseDto[]; deleted!: string[]; } + +export class SyncUserV1 { + id!: string; + name!: string; + email!: string; + deletedAt!: Date | null; +} + +export class SyncUserDeleteV1 { + userId!: string; +} + +export class SyncPartnerV1 { + sharedById!: string; + sharedWithId!: string; + inTimeline!: boolean; +} + +export class SyncPartnerDeleteV1 { + sharedById!: string; + sharedWithId!: string; +} + +export class SyncAssetV1 { + id!: string; + ownerId!: string; + thumbhash!: string | null; + checksum!: string; + fileCreatedAt!: Date | null; + fileModifiedAt!: Date | null; + localDateTime!: Date | null; + type!: AssetType; + deletedAt!: Date | null; + isFavorite!: boolean; + isVisible!: boolean; +} + +export class SyncAssetDeleteV1 { + assetId!: string; +} + +export class SyncAssetExifV1 { + assetId!: string; + description!: string | null; + @ApiProperty({ type: 'integer' }) + exifImageWidth!: number | null; + @ApiProperty({ type: 'integer' }) + exifImageHeight!: number | null; + @ApiProperty({ type: 'integer' }) + fileSizeInByte!: number | null; + orientation!: string | null; + dateTimeOriginal!: Date | null; + modifyDate!: Date | null; + timeZone!: string | null; + @ApiProperty({ type: 'integer' }) + latitude!: number | null; + @ApiProperty({ type: 'integer' }) + longitude!: number | null; + projectionType!: string | null; + city!: string | null; + state!: string | null; + country!: string | null; + make!: string | null; + model!: string | null; + lensModel!: string | null; + @ApiProperty({ type: 'integer' }) + fNumber!: number | null; + @ApiProperty({ type: 'integer' }) + focalLength!: number | null; + @ApiProperty({ type: 'integer' }) + iso!: number | null; + exposureTime!: string | null; + profileDescription!: string | null; + @ApiProperty({ type: 'integer' }) + rating!: number | null; + @ApiProperty({ type: 'integer' }) + fps!: number | null; +} + +export type SyncItem = { + [SyncEntityType.UserV1]: SyncUserV1; + [SyncEntityType.UserDeleteV1]: SyncUserDeleteV1; + [SyncEntityType.PartnerV1]: SyncPartnerV1; + [SyncEntityType.PartnerDeleteV1]: SyncPartnerDeleteV1; + [SyncEntityType.AssetV1]: SyncAssetV1; + [SyncEntityType.AssetDeleteV1]: SyncAssetDeleteV1; + [SyncEntityType.AssetExifV1]: SyncAssetExifV1; + [SyncEntityType.PartnerAssetV1]: SyncAssetV1; + [SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1; + [SyncEntityType.PartnerAssetExifV1]: SyncAssetExifV1; +}; + +const responseDtos = [ + // + SyncUserV1, + SyncUserDeleteV1, + SyncPartnerV1, + SyncPartnerDeleteV1, + SyncAssetV1, + SyncAssetDeleteV1, + SyncAssetExifV1, +]; + +export const extraSyncModels = responseDtos; + +export class SyncStreamDto { + @IsEnum(SyncRequestType, { each: true }) + @ApiProperty({ enumName: 'SyncRequestType', enum: SyncRequestType, isArray: true }) + types!: SyncRequestType[]; +} + +export class SyncAckDto { + @ApiProperty({ enumName: 'SyncEntityType', enum: SyncEntityType }) + type!: SyncEntityType; + ack!: string; +} + +export class SyncAckSetDto { + @IsString({ each: true }) + acks!: string[]; +} + +export class SyncAckDeleteDto { + @IsEnum(SyncEntityType, { each: true }) + @ApiProperty({ enumName: 'SyncEntityType', enum: SyncEntityType, isArray: true }) + @Optional() + types?: SyncEntityType[]; +} diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 3509182545..6b51c015b7 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -25,13 +25,14 @@ import { Colorspace, ImageFormat, LogLevel, + QueueName, ToneMapping, TranscodeHWAccel, TranscodePolicy, VideoCodec, VideoContainer, } from 'src/enum'; -import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface'; +import { ConcurrentQueueName } from 'src/types'; import { IsCronExpression, ValidateBoolean } from 'src/validation'; const isLibraryScanEnabled = (config: SystemConfigLibraryScanDto) => config.enabled; diff --git a/server/src/dtos/tag.dto.ts b/server/src/dtos/tag.dto.ts index cff11962d7..e62cf21636 100644 --- a/server/src/dtos/tag.dto.ts +++ b/server/src/dtos/tag.dto.ts @@ -1,8 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; import { IsHexColor, IsNotEmpty, IsString } from 'class-validator'; import { TagEntity } from 'src/entities/tag.entity'; -import { Optional, ValidateUUID } from 'src/validation'; +import { TagItem } from 'src/types'; +import { Optional, ValidateHexColor, ValidateUUID } from 'src/validation'; export class TagCreateDto { @IsString() @@ -18,9 +18,8 @@ export class TagCreateDto { } export class TagUpdateDto { - @Optional({ nullable: true, emptyToNull: true }) - @IsHexColor() - @Transform(({ value }) => (typeof value === 'string' && value[0] !== '#' ? `#${value}` : value)) + @Optional({ emptyToNull: true, nullable: true }) + @ValidateHexColor() color?: string | null; } @@ -53,7 +52,7 @@ export class TagResponseDto { color?: string; } -export function mapTag(entity: TagEntity): TagResponseDto { +export function mapTag(entity: TagItem | TagEntity): TagResponseDto { return { id: entity.id, parentId: entity.parentId ?? undefined, diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts index dd7a01df35..a9dfa49a07 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; import { AssetOrder } from 'src/enum'; -import { TimeBucketSize } from 'src/interfaces/asset.interface'; +import { TimeBucketSize } from 'src/repositories/asset.repository'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; export class TimeBucketDto { diff --git a/server/src/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts index 8de7021eaf..5a393a2d71 100644 --- a/server/src/dtos/user-preferences.dto.ts +++ b/server/src/dtos/user-preferences.dto.ts @@ -38,6 +38,14 @@ class PeopleUpdate { sidebarWeb?: boolean; } +class SharedLinksUpdate { + @ValidateBoolean({ optional: true }) + enabled?: boolean; + + @ValidateBoolean({ optional: true }) + sidebarWeb?: boolean; +} + class TagsUpdate { @ValidateBoolean({ optional: true }) enabled?: boolean; @@ -98,6 +106,11 @@ export class UserPreferencesUpdateDto { @Type(() => RatingsUpdate) ratings?: RatingsUpdate; + @Optional() + @ValidateNested() + @Type(() => SharedLinksUpdate) + sharedLinks?: SharedLinksUpdate; + @Optional() @ValidateNested() @Type(() => TagsUpdate) @@ -152,6 +165,11 @@ class TagsResponse { sidebarWeb: boolean = true; } +class SharedLinksResponse { + enabled: boolean = true; + sidebarWeb: boolean = false; +} + class EmailNotificationsResponse { enabled!: boolean; albumInvite!: boolean; @@ -175,6 +193,7 @@ export class UserPreferencesResponseDto implements UserPreferences { memories!: MemoriesResponse; people!: PeopleResponse; ratings!: RatingsResponse; + sharedLinks!: SharedLinksResponse; tags!: TagsResponse; avatar!: AvatarResponse; emailNotifications!: EmailNotificationsResponse; diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 593a7934bc..03895aa880 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -47,7 +47,7 @@ export const mapUser = (entity: UserEntity): UserResponseDto => { email: entity.email, name: entity.name, profileImagePath: entity.profileImagePath, - avatarColor: getPreferences(entity).avatar.color, + avatarColor: getPreferences(entity.email, entity.metadata || []).avatar.color, profileChangedAt: entity.profileChangedAt, }; }; @@ -157,6 +157,6 @@ export function mapUserAdmin(entity: UserEntity): UserAdminResponseDto { quotaSizeInBytes: entity.quotaSizeInBytes, quotaUsageInBytes: entity.quotaUsageInBytes, status: entity.status, - license: license ?? null, + license: license ? { ...license, activatedAt: new Date(license?.activatedAt) } : null, }; } diff --git a/server/src/emails/components/immich.layout.tsx b/server/src/emails/components/immich.layout.tsx index bb7a2aab65..911c6b31ee 100644 --- a/server/src/emails/components/immich.layout.tsx +++ b/server/src/emails/components/immich.layout.tsx @@ -50,7 +50,7 @@ export const ImmichLayout = ({ children, preview }: ImmichLayoutProps) => (
Immich
diff --git a/server/src/entities/activity.entity.ts b/server/src/entities/activity.entity.ts index 8de76ac894..dabb371977 100644 --- a/server/src/entities/activity.entity.ts +++ b/server/src/entities/activity.entity.ts @@ -25,6 +25,10 @@ export class ActivityEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_activity_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @Column() albumId!: string; diff --git a/server/src/entities/album.entity.ts b/server/src/entities/album.entity.ts index 5aec5a0f47..4cd7c82394 100644 --- a/server/src/entities/album.entity.ts +++ b/server/src/entities/album.entity.ts @@ -8,6 +8,7 @@ import { CreateDateColumn, DeleteDateColumn, Entity, + Index, JoinTable, ManyToMany, ManyToOne, @@ -39,6 +40,10 @@ export class AlbumEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_albums_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @DeleteDateColumn({ type: 'timestamptz' }) deletedAt!: Date | null; diff --git a/server/src/entities/api-key.entity.ts b/server/src/entities/api-key.entity.ts index 998ee4f8ef..f59bf0d918 100644 --- a/server/src/entities/api-key.entity.ts +++ b/server/src/entities/api-key.entity.ts @@ -1,6 +1,6 @@ import { UserEntity } from 'src/entities/user.entity'; import { Permission } from 'src/enum'; -import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; +import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; @Entity('api_keys') export class APIKeyEntity { @@ -27,4 +27,8 @@ export class APIKeyEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + + @Index('IDX_api_keys_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; } diff --git a/server/src/entities/asset-audit.entity.ts b/server/src/entities/asset-audit.entity.ts new file mode 100644 index 0000000000..0172d15ce6 --- /dev/null +++ b/server/src/entities/asset-audit.entity.ts @@ -0,0 +1,19 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm'; + +@Entity('assets_audit') +export class AssetAuditEntity { + @PrimaryColumn({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + id!: string; + + @Index('IDX_assets_audit_asset_id') + @Column({ type: 'uuid' }) + assetId!: string; + + @Index('IDX_assets_audit_owner_id') + @Column({ type: 'uuid' }) + ownerId!: string; + + @Index('IDX_assets_audit_deleted_at') + @CreateDateColumn({ type: 'timestamptz', default: () => 'clock_timestamp()' }) + deletedAt!: Date; +} diff --git a/server/src/entities/asset-face.entity.ts b/server/src/entities/asset-face.entity.ts index 3a4e916cba..b556a8b7cf 100644 --- a/server/src/entities/asset-face.entity.ts +++ b/server/src/entities/asset-face.entity.ts @@ -50,4 +50,7 @@ export class AssetFaceEntity { nullable: true, }) person!: PersonEntity | null; + + @Column({ type: 'timestamptz' }) + deletedAt!: Date | null; } diff --git a/server/src/entities/asset-files.entity.ts b/server/src/entities/asset-files.entity.ts index a8a6ddfee1..09f96e849d 100644 --- a/server/src/entities/asset-files.entity.ts +++ b/server/src/entities/asset-files.entity.ts @@ -30,6 +30,10 @@ export class AssetFileEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_asset_files_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @Column() type!: AssetFileType; diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index b7d3e7d4ab..b2589e1231 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -13,8 +13,8 @@ import { StackEntity } from 'src/entities/stack.entity'; import { TagEntity } from 'src/entities/tag.entity'; import { UserEntity } from 'src/entities/user.entity'; import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; -import { TimeBucketSize } from 'src/interfaces/asset.interface'; -import { AssetSearchBuilderOptions } from 'src/interfaces/search.interface'; +import { TimeBucketSize } from 'src/repositories/asset.repository'; +import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; import { anyUuid, asUuid } from 'src/utils/database'; import { Column, @@ -96,17 +96,21 @@ export class AssetEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_assets_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @DeleteDateColumn({ type: 'timestamptz', nullable: true }) deletedAt!: Date | null; @Index('idx_asset_file_created_at') - @Column({ type: 'timestamptz' }) + @Column({ type: 'timestamptz', nullable: true, default: null }) fileCreatedAt!: Date; - @Column({ type: 'timestamptz' }) + @Column({ type: 'timestamptz', nullable: true, default: null }) localDateTime!: Date; - @Column({ type: 'timestamptz' }) + @Column({ type: 'timestamptz', nullable: true, default: null }) fileModifiedAt!: Date; @Column({ type: 'boolean', default: false }) @@ -180,6 +184,12 @@ export class AssetEntity { duplicateId!: string | null; } +export type AssetEntityPlaceholder = AssetEntity & { + fileCreatedAt: Date | null; + fileModifiedAt: Date | null; + localDateTime: Date | null; +}; + export function withExif(qb: SelectQueryBuilder) { return qb.leftJoin('exif', 'assets.id', 'exif.assetId').select((eb) => eb.fn.toJson(eb.table('exif')).as('exifInfo')); } @@ -193,13 +203,17 @@ export function withExifInner(qb: SelectQueryBuilder) { export function withSmartSearch(qb: SelectQueryBuilder) { return qb .leftJoin('smart_search', 'assets.id', 'smart_search.assetId') - .select(sql`smart_search.embedding`.as('embedding')); + .select((eb) => eb.fn.toJson(eb.table('smart_search')).as('smartSearch')); } -export function withFaces(eb: ExpressionBuilder) { - return jsonArrayFrom(eb.selectFrom('asset_faces').selectAll().whereRef('asset_faces.assetId', '=', 'assets.id')).as( - 'faces', - ); +export function withFaces(eb: ExpressionBuilder, withDeletedFace?: boolean) { + return jsonArrayFrom( + eb + .selectFrom('asset_faces') + .selectAll() + .whereRef('asset_faces.assetId', '=', 'assets.id') + .$if(!withDeletedFace, (qb) => qb.where('asset_faces.deletedAt', 'is', null)), + ).as('faces'); } export function withFiles(eb: ExpressionBuilder, type?: AssetFileType) { @@ -212,11 +226,12 @@ export function withFiles(eb: ExpressionBuilder, type?: AssetFileT ).as('files'); } -export function withFacesAndPeople(eb: ExpressionBuilder) { +export function withFacesAndPeople(eb: ExpressionBuilder, withDeletedFace?: boolean) { return eb .selectFrom('asset_faces') .leftJoin('person', 'person.id', 'asset_faces.personId') .whereRef('asset_faces.assetId', '=', 'assets.id') + .$if(!withDeletedFace, (qb) => qb.where('asset_faces.deletedAt', 'is', null)) .select((eb) => eb .fn('jsonb_agg', [ @@ -238,22 +253,34 @@ export function withFacesAndPeople(eb: ExpressionBuilder) { .as('faces'); } -/** Adds a `has_people` CTE that can be inner joined on to filter out assets */ -export function hasPeopleCte(db: Kysely, personIds: string[]) { - return db.with('has_people', (qb) => - qb - .selectFrom('asset_faces') - .select('assetId') - .where('personId', '=', anyUuid(personIds!)) - .groupBy('assetId') - .having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length), +export function hasPeople(qb: SelectQueryBuilder, personIds: string[]) { + return qb.innerJoin( + (eb) => + eb + .selectFrom('asset_faces') + .select('assetId') + .where('personId', '=', anyUuid(personIds!)) + .where('deletedAt', 'is', null) + .groupBy('assetId') + .having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length) + .as('has_people'), + (join) => join.onRef('has_people.assetId', '=', 'assets.id'), ); } -export function hasPeople(db: Kysely, personIds?: string[]) { - return personIds && personIds.length > 0 - ? hasPeopleCte(db, personIds).selectFrom('assets').innerJoin('has_people', 'has_people.assetId', 'assets.id') - : db.selectFrom('assets'); +export function hasTags(qb: SelectQueryBuilder, tagIds: string[]) { + return qb.innerJoin( + (eb) => + eb + .selectFrom('tag_asset') + .select('assetsId') + .innerJoin('tags_closure', 'tag_asset.tagsId', 'tags_closure.id_descendant') + .where('tags_closure.id_ancestor', '=', anyUuid(tagIds)) + .groupBy('assetsId') + .having((eb) => eb.fn.count('tags_closure.id_ancestor').distinct(), '>=', tagIds.length) + .as('has_tags'), + (join) => join.onRef('has_tags.assetsId', '=', 'assets.id'), + ); } export function withOwner(eb: ExpressionBuilder) { @@ -325,9 +352,13 @@ const joinDeduplicationPlugin = new DeduplicateJoinsPlugin(); /** TODO: This should only be used for search-related queries, not as a general purpose query builder */ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuilderOptions) { options.isArchived ??= options.withArchived ? undefined : false; - options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore); - return hasPeople(kysely.withPlugin(joinDeduplicationPlugin), options.personIds) + options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore || options.isOffline); + return kysely + .withPlugin(joinDeduplicationPlugin) + .selectFrom('assets') .selectAll('assets') + .$if(!!options.tagIds && options.tagIds.length > 0, (qb) => hasTags(qb, options.tagIds!)) + .$if(!!options.personIds && options.personIds.length > 0, (qb) => hasPeople(qb, options.personIds!)) .$if(!!options.createdBefore, (qb) => qb.where('assets.createdAt', '<=', options.createdBefore!)) .$if(!!options.createdAfter, (qb) => qb.where('assets.createdAt', '>=', options.createdAfter!)) .$if(!!options.updatedBefore, (qb) => qb.where('assets.updatedAt', '<=', options.updatedBefore!)) @@ -366,6 +397,11 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .innerJoin('exif', 'assets.id', 'exif.assetId') .where('exif.lensModel', options.lensModel === null ? 'is' : '=', options.lensModel!), ) + .$if(options.rating !== undefined, (qb) => + qb + .innerJoin('exif', 'assets.id', 'exif.assetId') + .where('exif.rating', options.rating === null ? 'is' : '=', options.rating!), + ) .$if(!!options.checksum, (qb) => qb.where('assets.checksum', '=', options.checksum!)) .$if(!!options.deviceAssetId, (qb) => qb.where('assets.deviceAssetId', '=', options.deviceAssetId!)) .$if(!!options.deviceId, (qb) => qb.where('assets.deviceId', '=', options.deviceId!)) @@ -373,7 +409,9 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .$if(!!options.libraryId, (qb) => qb.where('assets.libraryId', '=', asUuid(options.libraryId!))) .$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!))) .$if(!!options.encodedVideoPath, (qb) => qb.where('assets.encodedVideoPath', '=', options.encodedVideoPath!)) - .$if(!!options.originalPath, (qb) => qb.where('assets.originalPath', '=', options.originalPath!)) + .$if(!!options.originalPath, (qb) => + qb.where(sql`f_unaccent(assets."originalPath")`, 'ilike', sql`'%' || f_unaccent(${options.originalPath}) || '%'`), + ) .$if(!!options.originalFileName, (qb) => qb.where( sql`f_unaccent(assets."originalFileName")`, @@ -381,6 +419,11 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild sql`'%' || f_unaccent(${options.originalFileName}) || '%'`, ), ) + .$if(!!options.description, (qb) => + qb + .innerJoin('exif', 'assets.id', 'exif.assetId') + .where(sql`f_unaccent(exif.description)`, 'ilike', sql`'%' || f_unaccent(${options.description}) || '%'`), + ) .$if(!!options.type, (qb) => qb.where('assets.type', '=', options.type!)) .$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!)) .$if(options.isOffline !== undefined, (qb) => qb.where('assets.isOffline', '=', options.isOffline!)) @@ -399,5 +442,8 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild ) .$if(!!options.withExif, withExifInner) .$if(!!(options.withFaces || options.withPeople || options.personIds), (qb) => qb.select(withFacesAndPeople)) - .$if(!options.withDeleted, (qb) => qb.where('assets.deletedAt', 'is', null)); + .$if(!options.withDeleted, (qb) => qb.where('assets.deletedAt', 'is', null)) + .where('assets.fileCreatedAt', 'is not', null) + .where('assets.fileModifiedAt', 'is not', null) + .where('assets.localDateTime', 'is not', null); } diff --git a/server/src/entities/exif.entity.ts b/server/src/entities/exif.entity.ts index c9c29d732a..5b402109a5 100644 --- a/server/src/entities/exif.entity.ts +++ b/server/src/entities/exif.entity.ts @@ -1,5 +1,5 @@ import { AssetEntity } from 'src/entities/asset.entity'; -import { Index, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm'; +import { Index, JoinColumn, OneToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm'; import { Column } from 'typeorm/decorator/columns/Column.js'; import { Entity } from 'typeorm/decorator/entity/Entity.js'; @@ -12,6 +12,13 @@ export class ExifEntity { @PrimaryColumn() assetId!: string; + @UpdateDateColumn({ type: 'timestamptz', default: () => 'clock_timestamp()' }) + updatedAt?: Date; + + @Index('IDX_asset_exif_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + /* General info */ @Column({ type: 'text', default: '' }) description!: string; // or caption diff --git a/server/src/entities/index.ts b/server/src/entities/index.ts deleted file mode 100644 index 75e92038ac..0000000000 --- a/server/src/entities/index.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { ActivityEntity } from 'src/entities/activity.entity'; -import { AlbumUserEntity } from 'src/entities/album-user.entity'; -import { AlbumEntity } from 'src/entities/album.entity'; -import { APIKeyEntity } from 'src/entities/api-key.entity'; -import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { AssetFileEntity } from 'src/entities/asset-files.entity'; -import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; -import { AssetEntity } from 'src/entities/asset.entity'; -import { AuditEntity } from 'src/entities/audit.entity'; -import { ExifEntity } from 'src/entities/exif.entity'; -import { FaceSearchEntity } from 'src/entities/face-search.entity'; -import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity'; -import { LibraryEntity } from 'src/entities/library.entity'; -import { MemoryEntity } from 'src/entities/memory.entity'; -import { MoveEntity } from 'src/entities/move.entity'; -import { NaturalEarthCountriesEntity } from 'src/entities/natural-earth-countries.entity'; -import { PartnerEntity } from 'src/entities/partner.entity'; -import { PersonEntity } from 'src/entities/person.entity'; -import { SessionEntity } from 'src/entities/session.entity'; -import { SharedLinkEntity } from 'src/entities/shared-link.entity'; -import { SmartSearchEntity } from 'src/entities/smart-search.entity'; -import { StackEntity } from 'src/entities/stack.entity'; -import { SystemMetadataEntity } from 'src/entities/system-metadata.entity'; -import { TagEntity } from 'src/entities/tag.entity'; -import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; -import { UserEntity } from 'src/entities/user.entity'; -import { VersionHistoryEntity } from 'src/entities/version-history.entity'; - -export const entities = [ - ActivityEntity, - AlbumEntity, - AlbumUserEntity, - APIKeyEntity, - AssetEntity, - AssetFaceEntity, - AssetFileEntity, - AssetJobStatusEntity, - AuditEntity, - ExifEntity, - FaceSearchEntity, - GeodataPlacesEntity, - NaturalEarthCountriesEntity, - MemoryEntity, - MoveEntity, - PartnerEntity, - PersonEntity, - SharedLinkEntity, - SmartSearchEntity, - StackEntity, - SystemMetadataEntity, - TagEntity, - UserEntity, - UserMetadataEntity, - SessionEntity, - LibraryEntity, - VersionHistoryEntity, -]; diff --git a/server/src/entities/library.entity.ts b/server/src/entities/library.entity.ts index a6053e4213..0471661fca 100644 --- a/server/src/entities/library.entity.ts +++ b/server/src/entities/library.entity.ts @@ -5,6 +5,7 @@ import { CreateDateColumn, DeleteDateColumn, Entity, + Index, JoinTable, ManyToOne, OneToMany, @@ -25,7 +26,7 @@ export class LibraryEntity { assets!: AssetEntity[]; @ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) - owner!: UserEntity; + owner?: UserEntity; @Column() ownerId!: string; @@ -42,6 +43,10 @@ export class LibraryEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_libraries_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @DeleteDateColumn({ type: 'timestamptz' }) deletedAt?: Date; diff --git a/server/src/entities/memory.entity.ts b/server/src/entities/memory.entity.ts index c8121dd32e..dafd7eb21c 100644 --- a/server/src/entities/memory.entity.ts +++ b/server/src/entities/memory.entity.ts @@ -6,6 +6,7 @@ import { CreateDateColumn, DeleteDateColumn, Entity, + Index, JoinTable, ManyToMany, ManyToOne, @@ -30,6 +31,10 @@ export class MemoryEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_memories_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @DeleteDateColumn({ type: 'timestamptz' }) deletedAt?: Date; @@ -53,6 +58,12 @@ export class MemoryEntity { @Column({ type: 'timestamptz' }) memoryAt!: Date; + @Column({ type: 'timestamptz', nullable: true }) + showAt?: Date; + + @Column({ type: 'timestamptz', nullable: true }) + hideAt?: Date; + /** when the user last viewed the memory */ @Column({ type: 'timestamptz', nullable: true }) seenAt?: Date; diff --git a/server/src/entities/move.entity.ts b/server/src/entities/move.entity.ts index 5cdef5d22e..7a998eaebe 100644 --- a/server/src/entities/move.entity.ts +++ b/server/src/entities/move.entity.ts @@ -10,7 +10,7 @@ export class MoveEntity { @PrimaryGeneratedColumn('uuid') id!: string; - @Column({ type: 'varchar' }) + @Column({ type: 'uuid' }) entityId!: string; @Column({ type: 'varchar' }) diff --git a/server/src/entities/partner-audit.entity.ts b/server/src/entities/partner-audit.entity.ts new file mode 100644 index 0000000000..a731e017dc --- /dev/null +++ b/server/src/entities/partner-audit.entity.ts @@ -0,0 +1,19 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm'; + +@Entity('partners_audit') +export class PartnerAuditEntity { + @PrimaryColumn({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + id!: string; + + @Index('IDX_partners_audit_shared_by_id') + @Column({ type: 'uuid' }) + sharedById!: string; + + @Index('IDX_partners_audit_shared_with_id') + @Column({ type: 'uuid' }) + sharedWithId!: string; + + @Index('IDX_partners_audit_deleted_at') + @CreateDateColumn({ type: 'timestamptz', default: () => 'clock_timestamp()' }) + deletedAt!: Date; +} diff --git a/server/src/entities/partner.entity.ts b/server/src/entities/partner.entity.ts index 189f6f51a7..877330a8e7 100644 --- a/server/src/entities/partner.entity.ts +++ b/server/src/entities/partner.entity.ts @@ -1,5 +1,14 @@ import { UserEntity } from 'src/entities/user.entity'; -import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm'; +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; @Entity('partners') export class PartnerEntity { @@ -23,6 +32,10 @@ export class PartnerEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_partners_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @Column({ type: 'boolean', default: false }) inTimeline!: boolean; } diff --git a/server/src/entities/person.entity.ts b/server/src/entities/person.entity.ts index 5efbcbfa0b..5efa602cc8 100644 --- a/server/src/entities/person.entity.ts +++ b/server/src/entities/person.entity.ts @@ -5,6 +5,7 @@ import { Column, CreateDateColumn, Entity, + Index, ManyToOne, OneToMany, PrimaryGeneratedColumn, @@ -23,6 +24,10 @@ export class PersonEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_person_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @Column() ownerId!: string; @@ -33,7 +38,7 @@ export class PersonEntity { name!: string; @Column({ type: 'date', nullable: true }) - birthDate!: string | null; + birthDate!: Date | string | null; @Column({ default: '' }) thumbnailPath!: string; @@ -49,4 +54,10 @@ export class PersonEntity { @Column({ default: false }) isHidden!: boolean; + + @Column({ default: false }) + isFavorite!: boolean; + + @Column({ type: 'varchar', nullable: true, default: null }) + color?: string | null; } diff --git a/server/src/entities/session.entity.ts b/server/src/entities/session.entity.ts index e21c6d52ba..cb208c958e 100644 --- a/server/src/entities/session.entity.ts +++ b/server/src/entities/session.entity.ts @@ -1,7 +1,7 @@ import { ExpressionBuilder } from 'kysely'; import { DB } from 'src/db'; import { UserEntity } from 'src/entities/user.entity'; -import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; +import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; @Entity('sessions') export class SessionEntity { @@ -23,6 +23,10 @@ export class SessionEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_sessions_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId!: string; + @Column({ default: '' }) deviceType!: string; diff --git a/server/src/entities/sync-checkpoint.entity.ts b/server/src/entities/sync-checkpoint.entity.ts new file mode 100644 index 0000000000..7c6818aba0 --- /dev/null +++ b/server/src/entities/sync-checkpoint.entity.ts @@ -0,0 +1,28 @@ +import { SessionEntity } from 'src/entities/session.entity'; +import { SyncEntityType } from 'src/enum'; +import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm'; + +@Entity('session_sync_checkpoints') +export class SessionSyncCheckpointEntity { + @ManyToOne(() => SessionEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + session?: SessionEntity; + + @PrimaryColumn() + sessionId!: string; + + @PrimaryColumn({ type: 'varchar' }) + type!: SyncEntityType; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; + + @Index('IDX_session_sync_checkpoints_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + + @Column() + ack!: string; +} diff --git a/server/src/entities/system-metadata.entity.ts b/server/src/entities/system-metadata.entity.ts index 678b8f701a..b024862ba5 100644 --- a/server/src/entities/system-metadata.entity.ts +++ b/server/src/entities/system-metadata.entity.ts @@ -14,6 +14,10 @@ export class SystemMetadataEntity }; +export type MemoriesState = { + /** memories have already been created through this date */ + lastOnThisDayDate: string; +}; export interface SystemMetadata extends Record> { [SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean }; @@ -23,4 +27,5 @@ export interface SystemMetadata extends Record; [SystemMetadataKey.SYSTEM_FLAGS]: DeepPartial; [SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata; + [SystemMetadataKey.MEMORIES_STATE]: MemoriesState; } diff --git a/server/src/entities/tag.entity.ts b/server/src/entities/tag.entity.ts index ebcc6853c9..fcbde6c779 100644 --- a/server/src/entities/tag.entity.ts +++ b/server/src/entities/tag.entity.ts @@ -4,6 +4,7 @@ import { Column, CreateDateColumn, Entity, + Index, ManyToMany, ManyToOne, PrimaryGeneratedColumn, @@ -30,6 +31,10 @@ export class TagEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_tags_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @Column({ type: 'varchar', nullable: true, default: null }) color!: string | null; diff --git a/server/src/entities/user-audit.entity.ts b/server/src/entities/user-audit.entity.ts new file mode 100644 index 0000000000..c29bc94d97 --- /dev/null +++ b/server/src/entities/user-audit.entity.ts @@ -0,0 +1,14 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm'; + +@Entity('users_audit') +export class UserAuditEntity { + @PrimaryColumn({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + id!: string; + + @Column({ type: 'uuid' }) + userId!: string; + + @Index('IDX_users_audit_deleted_at') + @CreateDateColumn({ type: 'timestamptz', default: () => 'clock_timestamp()' }) + deletedAt!: Date; +} diff --git a/server/src/entities/user-metadata.entity.ts b/server/src/entities/user-metadata.entity.ts index 2c901426c3..8c7a13ed0d 100644 --- a/server/src/entities/user-metadata.entity.ts +++ b/server/src/entities/user-metadata.entity.ts @@ -4,13 +4,18 @@ import { DeepPartial } from 'src/types'; import { HumanReadableSize } from 'src/utils/bytes'; import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm'; +export type UserMetadataItem = { + key: T; + value: UserMetadata[T]; +}; + @Entity('user_metadata') -export class UserMetadataEntity { +export class UserMetadataEntity implements UserMetadataItem { @PrimaryColumn({ type: 'uuid' }) userId!: string; @ManyToOne(() => UserEntity, (user) => user.metadata, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) - user!: UserEntity; + user?: UserEntity; @PrimaryColumn({ type: 'varchar' }) key!: T; @@ -34,6 +39,10 @@ export interface UserPreferences { ratings: { enabled: boolean; }; + sharedLinks: { + enabled: boolean; + sidebarWeb: boolean; + }; tags: { enabled: boolean; sidebarWeb: boolean; @@ -74,6 +83,10 @@ export const getDefaultPreferences = (user: { email: string }): UserPreferences enabled: true, sidebarWeb: false, }, + sharedLinks: { + enabled: true, + sidebarWeb: false, + }, ratings: { enabled: false, }, @@ -102,5 +115,5 @@ export const getDefaultPreferences = (user: { email: string }): UserPreferences export interface UserMetadata extends Record> { [UserMetadataKey.PREFERENCES]: DeepPartial; - [UserMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: Date }; + [UserMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: string }; } diff --git a/server/src/entities/user.entity.ts b/server/src/entities/user.entity.ts index 3f5b470ce4..5758e29098 100644 --- a/server/src/entities/user.entity.ts +++ b/server/src/entities/user.entity.ts @@ -10,12 +10,14 @@ import { CreateDateColumn, DeleteDateColumn, Entity, + Index, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; @Entity('users') +@Index('IDX_users_updated_at_asc_id_asc', ['updatedAt', 'id']) export class UserEntity { @PrimaryGeneratedColumn('uuid') id!: string; @@ -56,6 +58,10 @@ export class UserEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @Index('IDX_users_update_id') + @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' }) + updateId?: string; + @OneToMany(() => TagEntity, (tag) => tag.user) tags!: TagEntity[]; diff --git a/server/src/enum.ts b/server/src/enum.ts index 3440d45cee..6ebd1906f7 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -187,6 +187,7 @@ export enum StorageFolder { export enum SystemMetadataKey { REVERSE_GEOCODING_STATE = 'reverse-geocoding-state', FACIAL_RECOGNITION_STATE = 'facial-recognition-state', + MEMORIES_STATE = 'memories-state', ADMIN_ONBOARDING = 'admin-onboarding', SYSTEM_CONFIG = 'system-config', SYSTEM_FLAGS = 'system-flags', @@ -227,12 +228,16 @@ export enum AssetStatus { export enum SourceType { MACHINE_LEARNING = 'machine-learning', EXIF = 'exif', + MANUAL = 'manual', } export enum ManualJobName { PERSON_CLEANUP = 'person-cleanup', TAG_CLEANUP = 'tag-cleanup', USER_CLEANUP = 'user-cleanup', + MEMORY_CLEANUP = 'memory-cleanup', + MEMORY_CREATE = 'memory-create', + BACKUP_DATABASE = 'backup-database', } export enum AssetPathType { @@ -384,3 +389,185 @@ export enum ExifOrientation { MirrorHorizontalRotate90CW = 7, Rotate270CW = 8, } + +export enum DatabaseExtension { + CUBE = 'cube', + EARTH_DISTANCE = 'earthdistance', + VECTOR = 'vector', + VECTORS = 'vectors', +} + +export enum BootstrapEventPriority { + // Database service should be initialized before anything else, most other services need database access + DatabaseService = -200, + // Initialise config after other bootstrap services, stop other services from using config on bootstrap + SystemConfig = 100, +} + +export enum QueueName { + THUMBNAIL_GENERATION = 'thumbnailGeneration', + METADATA_EXTRACTION = 'metadataExtraction', + VIDEO_CONVERSION = 'videoConversion', + FACE_DETECTION = 'faceDetection', + FACIAL_RECOGNITION = 'facialRecognition', + SMART_SEARCH = 'smartSearch', + DUPLICATE_DETECTION = 'duplicateDetection', + BACKGROUND_TASK = 'backgroundTask', + STORAGE_TEMPLATE_MIGRATION = 'storageTemplateMigration', + MIGRATION = 'migration', + SEARCH = 'search', + SIDECAR = 'sidecar', + LIBRARY = 'library', + NOTIFICATION = 'notifications', + BACKUP_DATABASE = 'backupDatabase', +} + +export enum JobName { + //backups + BACKUP_DATABASE = 'database-backup', + + // conversion + QUEUE_VIDEO_CONVERSION = 'queue-video-conversion', + VIDEO_CONVERSION = 'video-conversion', + + // thumbnails + QUEUE_GENERATE_THUMBNAILS = 'queue-generate-thumbnails', + GENERATE_THUMBNAILS = 'generate-thumbnails', + GENERATE_PERSON_THUMBNAIL = 'generate-person-thumbnail', + + // metadata + QUEUE_METADATA_EXTRACTION = 'queue-metadata-extraction', + METADATA_EXTRACTION = 'metadata-extraction', + + // user + USER_DELETION = 'user-deletion', + USER_DELETE_CHECK = 'user-delete-check', + USER_SYNC_USAGE = 'user-sync-usage', + + // asset + ASSET_DELETION = 'asset-deletion', + ASSET_DELETION_CHECK = 'asset-deletion-check', + + // storage template + STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration', + STORAGE_TEMPLATE_MIGRATION_SINGLE = 'storage-template-migration-single', + + // tags + TAG_CLEANUP = 'tag-cleanup', + + // migration + QUEUE_MIGRATION = 'queue-migration', + MIGRATE_ASSET = 'migrate-asset', + MIGRATE_PERSON = 'migrate-person', + + // facial recognition + PERSON_CLEANUP = 'person-cleanup', + QUEUE_FACE_DETECTION = 'queue-face-detection', + FACE_DETECTION = 'face-detection', + QUEUE_FACIAL_RECOGNITION = 'queue-facial-recognition', + FACIAL_RECOGNITION = 'facial-recognition', + + // library management + LIBRARY_QUEUE_SYNC_FILES = 'library-queue-sync-files', + LIBRARY_QUEUE_SYNC_ASSETS = 'library-queue-sync-assets', + LIBRARY_SYNC_FILES = 'library-sync-files', + LIBRARY_SYNC_ASSETS = 'library-sync-assets', + LIBRARY_ASSET_REMOVAL = 'handle-library-file-deletion', + LIBRARY_DELETE = 'library-delete', + LIBRARY_QUEUE_SCAN_ALL = 'library-queue-scan-all', + LIBRARY_QUEUE_CLEANUP = 'library-queue-cleanup', + + // cleanup + DELETE_FILES = 'delete-files', + CLEAN_OLD_AUDIT_LOGS = 'clean-old-audit-logs', + CLEAN_OLD_SESSION_TOKENS = 'clean-old-session-tokens', + + // memories + MEMORIES_CLEANUP = 'memories-cleanup', + MEMORIES_CREATE = 'memories-create', + + // smart search + QUEUE_SMART_SEARCH = 'queue-smart-search', + SMART_SEARCH = 'smart-search', + + QUEUE_TRASH_EMPTY = 'queue-trash-empty', + + // duplicate detection + QUEUE_DUPLICATE_DETECTION = 'queue-duplicate-detection', + DUPLICATE_DETECTION = 'duplicate-detection', + + // XMP sidecars + QUEUE_SIDECAR = 'queue-sidecar', + SIDECAR_DISCOVERY = 'sidecar-discovery', + SIDECAR_SYNC = 'sidecar-sync', + SIDECAR_WRITE = 'sidecar-write', + + // Notification + NOTIFY_SIGNUP = 'notify-signup', + NOTIFY_ALBUM_INVITE = 'notify-album-invite', + NOTIFY_ALBUM_UPDATE = 'notify-album-update', + SEND_EMAIL = 'notification-send-email', + + // Version check + VERSION_CHECK = 'version-check', +} + +export enum JobCommand { + START = 'start', + PAUSE = 'pause', + RESUME = 'resume', + EMPTY = 'empty', + CLEAR_FAILED = 'clear-failed', +} + +export enum JobStatus { + SUCCESS = 'success', + FAILED = 'failed', + SKIPPED = 'skipped', +} + +export enum QueueCleanType { + FAILED = 'failed', +} + +export enum VectorIndex { + CLIP = 'clip_index', + FACE = 'face_index', +} + +export enum DatabaseLock { + GeodataImport = 100, + Migrations = 200, + SystemFileMounts = 300, + StorageTemplateMigration = 420, + VersionHistory = 500, + CLIPDimSize = 512, + Library = 1337, + GetSystemConfig = 69, + BackupDatabase = 42, +} + +export enum SyncRequestType { + UsersV1 = 'UsersV1', + PartnersV1 = 'PartnersV1', + AssetsV1 = 'AssetsV1', + AssetExifsV1 = 'AssetExifsV1', + PartnerAssetsV1 = 'PartnerAssetsV1', + PartnerAssetExifsV1 = 'PartnerAssetExifsV1', +} + +export enum SyncEntityType { + UserV1 = 'UserV1', + UserDeleteV1 = 'UserDeleteV1', + + PartnerV1 = 'PartnerV1', + PartnerDeleteV1 = 'PartnerDeleteV1', + + AssetV1 = 'AssetV1', + AssetDeleteV1 = 'AssetDeleteV1', + AssetExifV1 = 'AssetExifV1', + + PartnerAssetV1 = 'PartnerAssetV1', + PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1', + PartnerAssetExifV1 = 'PartnerAssetExifV1', +} diff --git a/server/src/interfaces/album.interface.ts b/server/src/interfaces/album.interface.ts deleted file mode 100644 index 7af1bd97e1..0000000000 --- a/server/src/interfaces/album.interface.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Insertable, Updateable } from 'kysely'; -import { Albums } from 'src/db'; -import { AlbumUserCreateDto } from 'src/dtos/album.dto'; -import { AlbumEntity } from 'src/entities/album.entity'; -import { IBulkAsset } from 'src/utils/asset.util'; - -export const IAlbumRepository = 'IAlbumRepository'; - -export interface AlbumAssetCount { - albumId: string; - assetCount: number; - startDate: Date | undefined; - endDate: Date | undefined; -} - -export interface AlbumInfoOptions { - withAssets: boolean; -} - -export interface IAlbumRepository extends IBulkAsset { - getById(id: string, options: AlbumInfoOptions): Promise; - getByAssetId(ownerId: string, assetId: string): Promise; - removeAsset(assetId: string): Promise; - getMetadataForIds(ids: string[]): Promise; - getOwned(ownerId: string): Promise; - getShared(ownerId: string): Promise; - getNotShared(ownerId: string): Promise; - restoreAll(userId: string): Promise; - softDeleteAll(userId: string): Promise; - deleteAll(userId: string): Promise; - create(album: Insertable, assetIds: string[], albumUsers: AlbumUserCreateDto[]): Promise; - update(id: string, album: Updateable): Promise; - delete(id: string): Promise; - updateThumbnails(): Promise; -} diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts deleted file mode 100644 index 5abaf9af26..0000000000 --- a/server/src/interfaces/asset.interface.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { Insertable, Updateable } from 'kysely'; -import { AssetFiles, AssetJobStatus, Assets, Exif } from 'src/db'; -import { AssetEntity } from 'src/entities/asset.entity'; -import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum'; -import { AssetSearchOptions, SearchExploreItem } from 'src/interfaces/search.interface'; -import { Paginated, PaginationOptions } from 'src/utils/pagination'; - -export type AssetStats = Record; - -export interface AssetStatsOptions { - isFavorite?: boolean; - isArchived?: boolean; - isTrashed?: boolean; -} - -export interface LivePhotoSearchOptions { - ownerId: string; - libraryId?: string | null; - livePhotoCID: string; - otherAssetId: string; - type: AssetType; -} - -export enum WithoutProperty { - THUMBNAIL = 'thumbnail', - ENCODED_VIDEO = 'encoded-video', - EXIF = 'exif', - SMART_SEARCH = 'smart-search', - DUPLICATE = 'duplicate', - FACES = 'faces', - SIDECAR = 'sidecar', -} - -export enum WithProperty { - SIDECAR = 'sidecar', -} - -export enum TimeBucketSize { - DAY = 'DAY', - MONTH = 'MONTH', -} - -export interface AssetBuilderOptions { - isArchived?: boolean; - isFavorite?: boolean; - isTrashed?: boolean; - isDuplicate?: boolean; - albumId?: string; - tagId?: string; - personId?: string; - userIds?: string[]; - withStacked?: boolean; - exifInfo?: boolean; - status?: AssetStatus; - assetType?: AssetType; -} - -export interface TimeBucketOptions extends AssetBuilderOptions { - size: TimeBucketSize; - order?: AssetOrder; -} - -export interface TimeBucketItem { - timeBucket: string; - count: number; -} - -export interface MonthDay { - day: number; - month: number; -} - -export interface AssetExploreFieldOptions { - maxFields: number; - minAssetsPerField: number; -} - -export interface AssetFullSyncOptions { - ownerId: string; - lastId?: string; - updatedUntil: Date; - limit: number; -} - -export interface AssetDeltaSyncOptions { - userIds: string[]; - updatedAfter: Date; - limit: number; -} - -export interface AssetUpdateDuplicateOptions { - targetDuplicateId: string | null; - assetIds: string[]; - duplicateIds: string[]; -} - -export interface UpsertFileOptions { - assetId: string; - type: AssetFileType; - path: string; -} - -export interface AssetGetByChecksumOptions { - ownerId: string; - checksum: Buffer; - libraryId?: string; -} - -export type AssetPathEntity = Pick; - -export interface GetByIdsRelations { - exifInfo?: boolean; - faces?: { person?: boolean }; - files?: boolean; - library?: boolean; - owner?: boolean; - smartSearch?: boolean; - stack?: { assets?: boolean }; - tags?: boolean; -} - -export interface DuplicateGroup { - duplicateId: string; - assets: AssetEntity[]; -} - -export interface DayOfYearAssets { - yearsAgo: number; - assets: AssetEntity[]; -} - -export const IAssetRepository = 'IAssetRepository'; - -export interface IAssetRepository { - create(asset: Insertable): Promise; - getByIds(ids: string[], relations?: GetByIdsRelations): Promise; - getByIdsWithAllRelations(ids: string[]): Promise; - getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise; - getByChecksum(options: AssetGetByChecksumOptions): Promise; - getByChecksums(userId: string, checksums: Buffer[]): Promise; - getUploadAssetIdByChecksum(ownerId: string, checksum: Buffer): Promise; - getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated; - getByDeviceIds(ownerId: string, deviceId: string, deviceAssetIds: string[]): Promise; - getByUserId(pagination: PaginationOptions, userId: string, options?: AssetSearchOptions): Paginated; - getById(id: string, relations?: GetByIdsRelations): Promise; - getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated; - getRandom(userIds: string[], count: number): Promise; - getLastUpdatedAssetForAlbumId(albumId: string): Promise; - getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise; - deleteAll(ownerId: string): Promise; - getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated; - getAllByDeviceId(userId: string, deviceId: string): Promise; - getLivePhotoCount(motionId: string): Promise; - updateAll(ids: string[], options: Updateable): Promise; - updateDuplicates(options: AssetUpdateDuplicateOptions): Promise; - update(asset: Updateable & { id: string }): Promise; - remove(asset: AssetEntity): Promise; - findLivePhotoMatch(options: LivePhotoSearchOptions): Promise; - getStatistics(ownerId: string, options: AssetStatsOptions): Promise; - getTimeBuckets(options: TimeBucketOptions): Promise; - getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise; - upsertExif(exif: Insertable): Promise; - upsertJobStatus(...jobStatus: Insertable[]): Promise; - getAssetIdByCity(userId: string, options: AssetExploreFieldOptions): Promise>; - getDuplicates(userId: string): Promise; - getAllForUserFullSync(options: AssetFullSyncOptions): Promise; - getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise; - upsertFile(options: Insertable): Promise; - upsertFiles(options: Insertable[]): Promise; -} diff --git a/server/src/interfaces/crypto.interface.ts b/server/src/interfaces/crypto.interface.ts deleted file mode 100644 index c661695cf7..0000000000 --- a/server/src/interfaces/crypto.interface.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const ICryptoRepository = 'ICryptoRepository'; - -export interface ICryptoRepository { - randomBytes(size: number): Buffer; - randomUUID(): string; - hashFile(filePath: string | Buffer): Promise; - hashSha256(data: string): string; - verifySha256(data: string, encrypted: string, publicKey: string): boolean; - hashSha1(data: string | Buffer): Buffer; - hashBcrypt(data: string | Buffer, saltOrRounds: string | number): Promise; - compareBcrypt(data: string | Buffer, encrypted: string): boolean; - newPassword(bytes: number): string; -} diff --git a/server/src/interfaces/database.interface.ts b/server/src/interfaces/database.interface.ts deleted file mode 100644 index 8cfc040271..0000000000 --- a/server/src/interfaces/database.interface.ts +++ /dev/null @@ -1,78 +0,0 @@ -export enum DatabaseExtension { - CUBE = 'cube', - EARTH_DISTANCE = 'earthdistance', - VECTOR = 'vector', - VECTORS = 'vectors', -} - -export type VectorExtension = DatabaseExtension.VECTOR | DatabaseExtension.VECTORS; - -export type DatabaseConnectionURL = { - connectionType: 'url'; - url: string; -}; - -export type DatabaseConnectionParts = { - connectionType: 'parts'; - host: string; - port: number; - username: string; - password: string; - database: string; -}; - -export type DatabaseConnectionParams = DatabaseConnectionURL | DatabaseConnectionParts; - -export enum VectorIndex { - CLIP = 'clip_index', - FACE = 'face_index', -} - -export enum DatabaseLock { - GeodataImport = 100, - Migrations = 200, - SystemFileMounts = 300, - StorageTemplateMigration = 420, - VersionHistory = 500, - CLIPDimSize = 512, - Library = 1337, - GetSystemConfig = 69, - BackupDatabase = 42, -} - -export const EXTENSION_NAMES: Record = { - cube: 'cube', - earthdistance: 'earthdistance', - vector: 'pgvector', - vectors: 'pgvecto.rs', -} as const; - -export interface ExtensionVersion { - availableVersion: string | null; - installedVersion: string | null; -} - -export interface VectorUpdateResult { - restartRequired: boolean; -} - -export const IDatabaseRepository = 'IDatabaseRepository'; - -export interface IDatabaseRepository { - init(): void; - reconnect(): Promise; - shutdown(): Promise; - getExtensionVersion(extension: DatabaseExtension): Promise; - getExtensionVersionRange(extension: VectorExtension): string; - getPostgresVersion(): Promise; - getPostgresVersionRange(): string; - createExtension(extension: DatabaseExtension): Promise; - updateVectorExtension(extension: VectorExtension, version?: string): Promise; - reindex(index: VectorIndex): Promise; - shouldReindex(name: VectorIndex): Promise; - runMigrations(options?: { transaction?: 'all' | 'none' | 'each' }): Promise; - withLock(lock: DatabaseLock, callback: () => Promise): Promise; - tryLock(lock: DatabaseLock): Promise; - isBusy(lock: DatabaseLock): boolean; - wait(lock: DatabaseLock): Promise; -} diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts deleted file mode 100644 index 9a9e23cca0..0000000000 --- a/server/src/interfaces/event.interface.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { ClassConstructor } from 'class-transformer'; -import { SystemConfig } from 'src/config'; -import { AssetResponseDto } from 'src/dtos/asset-response.dto'; -import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; -import { JobItem, QueueName } from 'src/interfaces/job.interface'; - -export const IEventRepository = 'IEventRepository'; - -type EventMap = { - // app events - 'app.bootstrap': []; - 'app.shutdown': []; - - 'config.init': [{ newConfig: SystemConfig }]; - // config events - 'config.update': [ - { - newConfig: SystemConfig; - oldConfig: SystemConfig; - }, - ]; - 'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; - - // album events - 'album.update': [{ id: string; recipientIds: string[] }]; - 'album.invite': [{ id: string; userId: string }]; - - // asset events - 'asset.tag': [{ assetId: string }]; - 'asset.untag': [{ assetId: string }]; - 'asset.hide': [{ assetId: string; userId: string }]; - 'asset.show': [{ assetId: string; userId: string }]; - 'asset.trash': [{ assetId: string; userId: string }]; - 'asset.delete': [{ assetId: string; userId: string }]; - - // asset bulk events - 'assets.trash': [{ assetIds: string[]; userId: string }]; - 'assets.delete': [{ assetIds: string[]; userId: string }]; - 'assets.restore': [{ assetIds: string[]; userId: string }]; - - 'job.start': [QueueName, JobItem]; - - // session events - 'session.delete': [{ sessionId: string }]; - - // stack events - 'stack.create': [{ stackId: string; userId: string }]; - 'stack.update': [{ stackId: string; userId: string }]; - 'stack.delete': [{ stackId: string; userId: string }]; - - // stack bulk events - 'stacks.delete': [{ stackIds: string[]; userId: string }]; - - // user events - 'user.signup': [{ notify: boolean; id: string; tempPassword?: string }]; - - // websocket events - 'websocket.connect': [{ userId: string }]; -}; - -export const serverEvents = ['config.update'] as const; -export type ServerEvents = (typeof serverEvents)[number]; - -export type EmitEvent = keyof EventMap; -export type EmitHandler = (...args: ArgsOf) => Promise | void; -export type ArgOf = EventMap[T][0]; -export type ArgsOf = EventMap[T]; - -export interface ClientEventMap { - on_upload_success: [AssetResponseDto]; - on_user_delete: [string]; - on_asset_delete: [string]; - on_asset_trash: [string[]]; - on_asset_update: [AssetResponseDto]; - on_asset_hidden: [string]; - on_asset_restore: [string[]]; - on_asset_stack_update: string[]; - on_person_thumbnail: [string]; - on_server_version: [ServerVersionResponseDto]; - on_config_update: []; - on_new_release: [ReleaseNotification]; - on_session_delete: [string]; -} - -export type EventItem = { - event: T; - handler: EmitHandler; - server: boolean; -}; - -export enum BootstrapEventPriority { - // Database service should be initialized before anything else, most other services need database access - DatabaseService = -200, - // Initialise config after other bootstrap services, stop other services from using config on bootstrap - SystemConfig = 100, -} - -export interface IEventRepository { - setup(options: { services: ClassConstructor[] }): void; - emit(event: T, ...args: ArgsOf): Promise; - - /** - * Send to connected clients for a specific user - */ - clientSend(event: E, room: string, ...data: ClientEventMap[E]): void; - /** - * Send to all connected clients - */ - clientBroadcast(event: E, ...data: ClientEventMap[E]): void; - /** - * Send to all connected servers - */ - serverSend(event: T, ...args: ArgsOf): void; -} diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts deleted file mode 100644 index 1f2b92074a..0000000000 --- a/server/src/interfaces/job.interface.ts +++ /dev/null @@ -1,329 +0,0 @@ -import { ClassConstructor } from 'class-transformer'; -import { EmailImageAttachment } from 'src/repositories/notification.repository'; - -export enum QueueName { - THUMBNAIL_GENERATION = 'thumbnailGeneration', - METADATA_EXTRACTION = 'metadataExtraction', - VIDEO_CONVERSION = 'videoConversion', - FACE_DETECTION = 'faceDetection', - FACIAL_RECOGNITION = 'facialRecognition', - SMART_SEARCH = 'smartSearch', - DUPLICATE_DETECTION = 'duplicateDetection', - BACKGROUND_TASK = 'backgroundTask', - STORAGE_TEMPLATE_MIGRATION = 'storageTemplateMigration', - MIGRATION = 'migration', - SEARCH = 'search', - SIDECAR = 'sidecar', - LIBRARY = 'library', - NOTIFICATION = 'notifications', - BACKUP_DATABASE = 'backupDatabase', -} - -export type ConcurrentQueueName = Exclude< - QueueName, - | QueueName.STORAGE_TEMPLATE_MIGRATION - | QueueName.FACIAL_RECOGNITION - | QueueName.DUPLICATE_DETECTION - | QueueName.BACKUP_DATABASE ->; - -export enum JobCommand { - START = 'start', - PAUSE = 'pause', - RESUME = 'resume', - EMPTY = 'empty', - CLEAR_FAILED = 'clear-failed', -} - -export enum JobName { - //backups - BACKUP_DATABASE = 'database-backup', - - // conversion - QUEUE_VIDEO_CONVERSION = 'queue-video-conversion', - VIDEO_CONVERSION = 'video-conversion', - - // thumbnails - QUEUE_GENERATE_THUMBNAILS = 'queue-generate-thumbnails', - GENERATE_THUMBNAILS = 'generate-thumbnails', - GENERATE_PERSON_THUMBNAIL = 'generate-person-thumbnail', - - // metadata - QUEUE_METADATA_EXTRACTION = 'queue-metadata-extraction', - METADATA_EXTRACTION = 'metadata-extraction', - LINK_LIVE_PHOTOS = 'link-live-photos', - - // user - USER_DELETION = 'user-deletion', - USER_DELETE_CHECK = 'user-delete-check', - USER_SYNC_USAGE = 'user-sync-usage', - - // asset - ASSET_DELETION = 'asset-deletion', - ASSET_DELETION_CHECK = 'asset-deletion-check', - - // storage template - STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration', - STORAGE_TEMPLATE_MIGRATION_SINGLE = 'storage-template-migration-single', - - // tags - TAG_CLEANUP = 'tag-cleanup', - - // migration - QUEUE_MIGRATION = 'queue-migration', - MIGRATE_ASSET = 'migrate-asset', - MIGRATE_PERSON = 'migrate-person', - - // facial recognition - PERSON_CLEANUP = 'person-cleanup', - QUEUE_FACE_DETECTION = 'queue-face-detection', - FACE_DETECTION = 'face-detection', - QUEUE_FACIAL_RECOGNITION = 'queue-facial-recognition', - FACIAL_RECOGNITION = 'facial-recognition', - - // library management - LIBRARY_QUEUE_SYNC_FILES = 'library-queue-sync-files', - LIBRARY_QUEUE_SYNC_ASSETS = 'library-queue-sync-assets', - LIBRARY_SYNC_FILE = 'library-sync-file', - LIBRARY_SYNC_ASSET = 'library-sync-asset', - LIBRARY_DELETE = 'library-delete', - LIBRARY_QUEUE_SYNC_ALL = 'library-queue-sync-all', - LIBRARY_QUEUE_CLEANUP = 'library-queue-cleanup', - - // cleanup - DELETE_FILES = 'delete-files', - CLEAN_OLD_AUDIT_LOGS = 'clean-old-audit-logs', - CLEAN_OLD_SESSION_TOKENS = 'clean-old-session-tokens', - - // smart search - QUEUE_SMART_SEARCH = 'queue-smart-search', - SMART_SEARCH = 'smart-search', - - QUEUE_TRASH_EMPTY = 'queue-trash-empty', - - // duplicate detection - QUEUE_DUPLICATE_DETECTION = 'queue-duplicate-detection', - DUPLICATE_DETECTION = 'duplicate-detection', - - // XMP sidecars - QUEUE_SIDECAR = 'queue-sidecar', - SIDECAR_DISCOVERY = 'sidecar-discovery', - SIDECAR_SYNC = 'sidecar-sync', - SIDECAR_WRITE = 'sidecar-write', - - // Notification - NOTIFY_SIGNUP = 'notify-signup', - NOTIFY_ALBUM_INVITE = 'notify-album-invite', - NOTIFY_ALBUM_UPDATE = 'notify-album-update', - SEND_EMAIL = 'notification-send-email', - - // Version check - VERSION_CHECK = 'version-check', -} - -export const JOBS_ASSET_PAGINATION_SIZE = 1000; -export const JOBS_LIBRARY_PAGINATION_SIZE = 10_000; - -export interface IBaseJob { - force?: boolean; -} - -export interface IDelayedJob extends IBaseJob { - /** The minimum time to wait to execute this job, in milliseconds. */ - delay?: number; -} - -export interface IEntityJob extends IBaseJob { - id: string; - source?: 'upload' | 'sidecar-write' | 'copy'; - notify?: boolean; -} - -export interface IAssetDeleteJob extends IEntityJob { - deleteOnDisk: boolean; -} - -export interface ILibraryFileJob extends IEntityJob { - ownerId: string; - assetPath: string; -} - -export interface ILibraryAssetJob extends IEntityJob { - importPaths: string[]; - exclusionPatterns: string[]; -} - -export interface IBulkEntityJob extends IBaseJob { - ids: string[]; -} - -export interface IDeleteFilesJob extends IBaseJob { - files: Array; -} - -export interface ISidecarWriteJob extends IEntityJob { - description?: string; - dateTimeOriginal?: string; - latitude?: number; - longitude?: number; - rating?: number; - tags?: true; -} - -export interface IDeferrableJob extends IEntityJob { - deferred?: boolean; -} - -export interface INightlyJob extends IBaseJob { - nightly?: boolean; -} - -export interface IEmailJob { - to: string; - subject: string; - html: string; - text: string; - imageAttachments?: EmailImageAttachment[]; -} - -export interface INotifySignupJob extends IEntityJob { - tempPassword?: string; -} - -export interface INotifyAlbumInviteJob extends IEntityJob { - recipientId: string; -} - -export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob { - recipientIds: string[]; -} - -export interface JobCounts { - active: number; - completed: number; - failed: number; - delayed: number; - waiting: number; - paused: number; -} - -export interface QueueStatus { - isActive: boolean; - isPaused: boolean; -} - -export enum QueueCleanType { - FAILED = 'failed', -} - -export type JobItem = - // Backups - | { name: JobName.BACKUP_DATABASE; data?: IBaseJob } - - // Transcoding - | { name: JobName.QUEUE_VIDEO_CONVERSION; data: IBaseJob } - | { name: JobName.VIDEO_CONVERSION; data: IEntityJob } - - // Thumbnails - | { name: JobName.QUEUE_GENERATE_THUMBNAILS; data: IBaseJob } - | { name: JobName.GENERATE_THUMBNAILS; data: IEntityJob } - - // User - | { name: JobName.USER_DELETE_CHECK; data?: IBaseJob } - | { name: JobName.USER_DELETION; data: IEntityJob } - | { name: JobName.USER_SYNC_USAGE; data?: IBaseJob } - - // Storage Template - | { name: JobName.STORAGE_TEMPLATE_MIGRATION; data?: IBaseJob } - | { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE; data: IEntityJob } - - // Migration - | { name: JobName.QUEUE_MIGRATION; data?: IBaseJob } - | { name: JobName.MIGRATE_ASSET; data: IEntityJob } - | { name: JobName.MIGRATE_PERSON; data: IEntityJob } - - // Metadata Extraction - | { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob } - | { name: JobName.METADATA_EXTRACTION; data: IEntityJob } - | { name: JobName.LINK_LIVE_PHOTOS; data: IEntityJob } - // Sidecar Scanning - | { name: JobName.QUEUE_SIDECAR; data: IBaseJob } - | { name: JobName.SIDECAR_DISCOVERY; data: IEntityJob } - | { name: JobName.SIDECAR_SYNC; data: IEntityJob } - | { name: JobName.SIDECAR_WRITE; data: ISidecarWriteJob } - - // Facial Recognition - | { name: JobName.QUEUE_FACE_DETECTION; data: IBaseJob } - | { name: JobName.FACE_DETECTION; data: IEntityJob } - | { name: JobName.QUEUE_FACIAL_RECOGNITION; data: INightlyJob } - | { name: JobName.FACIAL_RECOGNITION; data: IDeferrableJob } - | { name: JobName.GENERATE_PERSON_THUMBNAIL; data: IEntityJob } - - // Smart Search - | { name: JobName.QUEUE_SMART_SEARCH; data: IBaseJob } - | { name: JobName.SMART_SEARCH; data: IEntityJob } - | { name: JobName.QUEUE_TRASH_EMPTY; data?: IBaseJob } - - // Duplicate Detection - | { name: JobName.QUEUE_DUPLICATE_DETECTION; data: IBaseJob } - | { name: JobName.DUPLICATE_DETECTION; data: IEntityJob } - - // Filesystem - | { name: JobName.DELETE_FILES; data: IDeleteFilesJob } - - // Cleanup - | { name: JobName.CLEAN_OLD_AUDIT_LOGS; data?: IBaseJob } - | { name: JobName.CLEAN_OLD_SESSION_TOKENS; data?: IBaseJob } - - // Tags - | { name: JobName.TAG_CLEANUP; data?: IBaseJob } - - // Asset Deletion - | { name: JobName.PERSON_CLEANUP; data?: IBaseJob } - | { name: JobName.ASSET_DELETION; data: IAssetDeleteJob } - | { name: JobName.ASSET_DELETION_CHECK; data?: IBaseJob } - - // Library Management - | { name: JobName.LIBRARY_SYNC_FILE; data: ILibraryFileJob } - | { name: JobName.LIBRARY_QUEUE_SYNC_FILES; data: IEntityJob } - | { name: JobName.LIBRARY_QUEUE_SYNC_ASSETS; data: IEntityJob } - | { name: JobName.LIBRARY_SYNC_ASSET; data: ILibraryAssetJob } - | { name: JobName.LIBRARY_DELETE; data: IEntityJob } - | { name: JobName.LIBRARY_QUEUE_SYNC_ALL; data?: IBaseJob } - | { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob } - - // Notification - | { name: JobName.SEND_EMAIL; data: IEmailJob } - | { name: JobName.NOTIFY_ALBUM_INVITE; data: INotifyAlbumInviteJob } - | { name: JobName.NOTIFY_ALBUM_UPDATE; data: INotifyAlbumUpdateJob } - | { name: JobName.NOTIFY_SIGNUP; data: INotifySignupJob } - - // Version check - | { name: JobName.VERSION_CHECK; data: IBaseJob }; - -export enum JobStatus { - SUCCESS = 'success', - FAILED = 'failed', - SKIPPED = 'skipped', -} -export type Jobs = { [K in JobItem['name']]: (JobItem & { name: K })['data'] }; -export type JobOf = Jobs[T]; - -export const IJobRepository = 'IJobRepository'; - -export interface IJobRepository { - setup(options: { services: ClassConstructor[] }): void; - startWorkers(): void; - run(job: JobItem): Promise; - setConcurrency(queueName: QueueName, concurrency: number): void; - queue(item: JobItem): Promise; - queueAll(items: JobItem[]): Promise; - pause(name: QueueName): Promise; - resume(name: QueueName): Promise; - empty(name: QueueName): Promise; - clear(name: QueueName, type: QueueCleanType): Promise; - getQueueStatus(name: QueueName): Promise; - getJobCounts(name: QueueName): Promise; - waitForQueueCompletion(...queues: QueueName[]): Promise; - removeJob(jobId: string, name: JobName): Promise; -} diff --git a/server/src/interfaces/library.interface.ts b/server/src/interfaces/library.interface.ts deleted file mode 100644 index 66e9a7de29..0000000000 --- a/server/src/interfaces/library.interface.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Insertable, Updateable } from 'kysely'; -import { Libraries } from 'src/db'; -import { LibraryStatsResponseDto } from 'src/dtos/library.dto'; -import { LibraryEntity } from 'src/entities/library.entity'; - -export const ILibraryRepository = 'ILibraryRepository'; - -export interface ILibraryRepository { - getAll(withDeleted?: boolean): Promise; - getAllDeleted(): Promise; - get(id: string, withDeleted?: boolean): Promise; - create(library: Insertable): Promise; - delete(id: string): Promise; - softDelete(id: string): Promise; - update(id: string, library: Updateable): Promise; - getStatistics(id: string): Promise; -} diff --git a/server/src/interfaces/machine-learning.interface.ts b/server/src/interfaces/machine-learning.interface.ts deleted file mode 100644 index 934091ef8e..0000000000 --- a/server/src/interfaces/machine-learning.interface.ts +++ /dev/null @@ -1,57 +0,0 @@ -export const IMachineLearningRepository = 'IMachineLearningRepository'; - -export interface BoundingBox { - x1: number; - y1: number; - x2: number; - y2: number; -} - -export enum ModelTask { - FACIAL_RECOGNITION = 'facial-recognition', - SEARCH = 'clip', -} - -export enum ModelType { - DETECTION = 'detection', - PIPELINE = 'pipeline', - RECOGNITION = 'recognition', - TEXTUAL = 'textual', - VISUAL = 'visual', -} - -export type ModelPayload = { imagePath: string } | { text: string }; - -type ModelOptions = { modelName: string }; - -export type FaceDetectionOptions = ModelOptions & { minScore: number }; - -type VisualResponse = { imageHeight: number; imageWidth: number }; -export type ClipVisualRequest = { [ModelTask.SEARCH]: { [ModelType.VISUAL]: ModelOptions } }; -export type ClipVisualResponse = { [ModelTask.SEARCH]: string } & VisualResponse; - -export type ClipTextualRequest = { [ModelTask.SEARCH]: { [ModelType.TEXTUAL]: ModelOptions } }; -export type ClipTextualResponse = { [ModelTask.SEARCH]: string }; - -export type FacialRecognitionRequest = { - [ModelTask.FACIAL_RECOGNITION]: { - [ModelType.DETECTION]: ModelOptions & { options: { minScore: number } }; - [ModelType.RECOGNITION]: ModelOptions; - }; -}; - -export interface Face { - boundingBox: BoundingBox; - embedding: string; - score: number; -} - -export type FacialRecognitionResponse = { [ModelTask.FACIAL_RECOGNITION]: Face[] } & VisualResponse; -export type DetectedFaces = { faces: Face[] } & VisualResponse; -export type MachineLearningRequest = ClipVisualRequest | ClipTextualRequest | FacialRecognitionRequest; - -export interface IMachineLearningRepository { - encodeImage(urls: string[], imagePath: string, config: ModelOptions): Promise; - encodeText(urls: string[], text: string, config: ModelOptions): Promise; - detectFaces(urls: string[], imagePath: string, config: FaceDetectionOptions): Promise; -} diff --git a/server/src/interfaces/move.interface.ts b/server/src/interfaces/move.interface.ts deleted file mode 100644 index 4356d9df8c..0000000000 --- a/server/src/interfaces/move.interface.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Insertable, Updateable } from 'kysely'; -import { MoveHistory } from 'src/db'; -import { MoveEntity } from 'src/entities/move.entity'; -import { PathType } from 'src/enum'; - -export const IMoveRepository = 'IMoveRepository'; - -export type MoveCreate = Pick & Partial; - -export interface IMoveRepository { - create(entity: Insertable): Promise; - getByEntity(entityId: string, pathType: PathType): Promise; - update(id: string, entity: Updateable): Promise; - delete(id: string): Promise; -} diff --git a/server/src/interfaces/partner.interface.ts b/server/src/interfaces/partner.interface.ts deleted file mode 100644 index a6f50178ca..0000000000 --- a/server/src/interfaces/partner.interface.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Updateable } from 'kysely'; -import { Partners } from 'src/db'; -import { PartnerEntity } from 'src/entities/partner.entity'; - -export interface PartnerIds { - sharedById: string; - sharedWithId: string; -} - -export enum PartnerDirection { - SharedBy = 'shared-by', - SharedWith = 'shared-with', -} - -export const IPartnerRepository = 'IPartnerRepository'; - -export interface IPartnerRepository { - getAll(userId: string): Promise; - get(partner: PartnerIds): Promise; - create(partner: PartnerIds): Promise; - remove(partner: PartnerIds): Promise; - update(partner: PartnerIds, entity: Updateable): Promise; -} diff --git a/server/src/interfaces/person.interface.ts b/server/src/interfaces/person.interface.ts deleted file mode 100644 index 4719f047ec..0000000000 --- a/server/src/interfaces/person.interface.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Insertable, Selectable, Updateable } from 'kysely'; -import { AssetFaces, FaceSearch, Person } from 'src/db'; -import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { PersonEntity } from 'src/entities/person.entity'; -import { SourceType } from 'src/enum'; -import { Paginated, PaginationOptions } from 'src/utils/pagination'; -import { FindOptionsRelations } from 'typeorm'; - -export const IPersonRepository = 'IPersonRepository'; - -export interface PersonSearchOptions { - minimumFaceCount: number; - withHidden: boolean; - closestFaceAssetId?: string; -} - -export interface PersonNameSearchOptions { - withHidden?: boolean; -} - -export interface PersonNameResponse { - id: string; - name: string; -} - -export interface AssetFaceId { - assetId: string; - personId: string; -} - -export interface UpdateFacesData { - oldPersonId?: string; - faceIds?: string[]; - newPersonId: string; -} - -export interface PersonStatistics { - assets: number; -} - -export interface PeopleStatistics { - total: number; - hidden: number; -} - -export interface DeleteFacesOptions { - sourceType: SourceType; -} - -export type UnassignFacesOptions = DeleteFacesOptions; - -export type SelectFaceOptions = (keyof Selectable)[]; - -export interface IPersonRepository { - getAll(options?: Partial): AsyncIterableIterator; - getAllForUser(pagination: PaginationOptions, userId: string, options: PersonSearchOptions): Paginated; - getAllWithoutFaces(): Promise; - getById(personId: string): Promise; - getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise; - getDistinctNames(userId: string, options: PersonNameSearchOptions): Promise; - - create(person: Insertable): Promise; - createAll(people: Insertable[]): Promise; - delete(entities: PersonEntity[]): Promise; - deleteFaces(options: DeleteFacesOptions): Promise; - refreshFaces( - facesToAdd: Insertable[], - faceIdsToRemove: string[], - embeddingsToAdd?: Insertable[], - ): Promise; - getAllFaces(options?: Partial): AsyncIterableIterator; - getFaceById(id: string): Promise; - getFaceByIdWithAssets( - id: string, - relations?: FindOptionsRelations, - select?: SelectFaceOptions, - ): Promise; - getFaces(assetId: string): Promise; - getFacesByIds(ids: AssetFaceId[]): Promise; - getRandomFace(personId: string): Promise; - getStatistics(personId: string): Promise; - reassignFace(assetFaceId: string, newPersonId: string): Promise; - getNumberOfPeople(userId: string): Promise; - reassignFaces(data: UpdateFacesData): Promise; - unassignFaces(options: UnassignFacesOptions): Promise; - update(person: Updateable & { id: string }): Promise; - updateAll(people: Insertable[]): Promise; - getLatestFaceDate(): Promise; -} diff --git a/server/src/interfaces/process.interface.ts b/server/src/interfaces/process.interface.ts deleted file mode 100644 index 14a8c1ff33..0000000000 --- a/server/src/interfaces/process.interface.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ChildProcessWithoutNullStreams, SpawnOptionsWithoutStdio } from 'node:child_process'; -import { Readable } from 'node:stream'; - -export interface ImmichReadStream { - stream: Readable; - type?: string; - length?: number; -} - -export interface ImmichZipStream extends ImmichReadStream { - addFile: (inputPath: string, filename: string) => void; - finalize: () => Promise; -} - -export interface DiskUsage { - available: number; - free: number; - total: number; -} - -export const IProcessRepository = 'IProcessRepository'; - -export interface IProcessRepository { - spawn(command: string, args?: readonly string[], options?: SpawnOptionsWithoutStdio): ChildProcessWithoutNullStreams; -} diff --git a/server/src/interfaces/search.interface.ts b/server/src/interfaces/search.interface.ts deleted file mode 100644 index bb76ff7b1f..0000000000 --- a/server/src/interfaces/search.interface.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { AssetEntity } from 'src/entities/asset.entity'; -import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity'; -import { AssetStatus, AssetType } from 'src/enum'; -import { Paginated } from 'src/utils/pagination'; - -export const ISearchRepository = 'ISearchRepository'; - -export interface SearchResult { - /** total matches */ - total: number; - /** collection size */ - count: number; - /** current page */ - page: number; - /** items for page */ - items: T[]; - /** score */ - distances: number[]; - facets: SearchFacet[]; -} - -export interface SearchFacet { - fieldName: string; - counts: Array<{ - count: number; - value: string; - }>; -} - -export type SearchExploreItemSet = Array<{ - value: string; - data: T; -}>; - -export interface SearchExploreItem { - fieldName: string; - items: SearchExploreItemSet; -} - -export interface SearchAssetIDOptions { - checksum?: Buffer; - deviceAssetId?: string; - id?: string; -} - -export interface SearchUserIdOptions { - deviceId?: string; - libraryId?: string | null; - userIds?: string[]; -} - -export type SearchIdOptions = SearchAssetIDOptions & SearchUserIdOptions; - -export interface SearchStatusOptions { - isArchived?: boolean; - isEncoded?: boolean; - isFavorite?: boolean; - isMotion?: boolean; - isOffline?: boolean; - isVisible?: boolean; - isNotInAlbum?: boolean; - type?: AssetType; - status?: AssetStatus; - withArchived?: boolean; - withDeleted?: boolean; -} - -export interface SearchOneToOneRelationOptions { - withExif?: boolean; - withStacked?: boolean; -} - -export interface SearchRelationOptions extends SearchOneToOneRelationOptions { - withFaces?: boolean; - withPeople?: boolean; -} - -export interface SearchDateOptions { - createdBefore?: Date; - createdAfter?: Date; - takenBefore?: Date; - takenAfter?: Date; - trashedBefore?: Date; - trashedAfter?: Date; - updatedBefore?: Date; - updatedAfter?: Date; -} - -export interface SearchPathOptions { - encodedVideoPath?: string; - originalFileName?: string; - originalPath?: string; - previewPath?: string; - thumbnailPath?: string; -} - -export interface SearchExifOptions { - city?: string | null; - country?: string | null; - lensModel?: string | null; - make?: string | null; - model?: string | null; - state?: string | null; -} - -export interface SearchEmbeddingOptions { - embedding: string; - userIds: string[]; -} - -export interface SearchPeopleOptions { - personIds?: string[]; -} - -export interface SearchOrderOptions { - orderDirection?: 'asc' | 'desc'; -} - -export interface SearchPaginationOptions { - page: number; - size: number; -} - -type BaseAssetSearchOptions = SearchDateOptions & - SearchIdOptions & - SearchExifOptions & - SearchOrderOptions & - SearchPathOptions & - SearchStatusOptions & - SearchUserIdOptions & - SearchPeopleOptions; - -export type AssetSearchOptions = BaseAssetSearchOptions & SearchRelationOptions; - -export type AssetSearchOneToOneRelationOptions = BaseAssetSearchOptions & SearchOneToOneRelationOptions; - -export type AssetSearchBuilderOptions = Omit; - -export type SmartSearchOptions = SearchDateOptions & - SearchEmbeddingOptions & - SearchExifOptions & - SearchOneToOneRelationOptions & - SearchStatusOptions & - SearchUserIdOptions & - SearchPeopleOptions; - -export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { - hasPerson?: boolean; - numResults: number; - maxDistance: number; -} - -export interface AssetDuplicateSearch { - assetId: string; - embedding: string; - maxDistance: number; - type: AssetType; - userIds: string[]; -} - -export interface FaceSearchResult { - distance: number; - id: string; - personId: string | null; -} - -export interface AssetDuplicateResult { - assetId: string; - duplicateId: string | null; - distance: number; -} - -export interface GetStatesOptions { - country?: string; -} - -export interface GetCitiesOptions extends GetStatesOptions { - state?: string; -} - -export interface GetCameraModelsOptions { - make?: string; -} - -export interface GetCameraMakesOptions { - model?: string; -} - -export interface ISearchRepository { - searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated; - searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated; - searchDuplicates(options: AssetDuplicateSearch): Promise; - searchFaces(search: FaceEmbeddingSearch): Promise; - searchRandom(size: number, options: AssetSearchOptions): Promise; - upsert(assetId: string, embedding: string): Promise; - searchPlaces(placeName: string): Promise; - getAssetsByCity(userIds: string[]): Promise; - deleteAllSearchEmbeddings(): Promise; - getDimensionSize(): Promise; - setDimensionSize(dimSize: number): Promise; - getCountries(userIds: string[]): Promise>; - getStates(userIds: string[], options: GetStatesOptions): Promise>; - getCities(userIds: string[], options: GetCitiesOptions): Promise>; - getCameraMakes(userIds: string[], options: GetCameraMakesOptions): Promise>; - getCameraModels(userIds: string[], options: GetCameraModelsOptions): Promise>; -} diff --git a/server/src/interfaces/session.interface.ts b/server/src/interfaces/session.interface.ts deleted file mode 100644 index 8d695fbfc2..0000000000 --- a/server/src/interfaces/session.interface.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Insertable, Updateable } from 'kysely'; -import { Sessions } from 'src/db'; -import { SessionEntity } from 'src/entities/session.entity'; - -export const ISessionRepository = 'ISessionRepository'; - -type E = SessionEntity; -export type SessionSearchOptions = { updatedBefore: Date }; - -export interface ISessionRepository { - search(options: SessionSearchOptions): Promise; - create(dto: Insertable): Promise; - update(id: string, dto: Updateable): Promise; - delete(id: string): Promise; - getByToken(token: string): Promise; - getByUserId(userId: string): Promise; -} diff --git a/server/src/interfaces/shared-link.interface.ts b/server/src/interfaces/shared-link.interface.ts deleted file mode 100644 index 25b7237f00..0000000000 --- a/server/src/interfaces/shared-link.interface.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Insertable, Updateable } from 'kysely'; -import { SharedLinks } from 'src/db'; -import { SharedLinkEntity } from 'src/entities/shared-link.entity'; - -export const ISharedLinkRepository = 'ISharedLinkRepository'; - -export interface ISharedLinkRepository { - getAll(userId: string): Promise; - get(userId: string, id: string): Promise; - getByKey(key: Buffer): Promise; - create(entity: Insertable & { assetIds?: string[] }): Promise; - update(entity: Updateable & { id: string; assetIds?: string[] }): Promise; - remove(entity: SharedLinkEntity): Promise; -} diff --git a/server/src/interfaces/stack.interface.ts b/server/src/interfaces/stack.interface.ts deleted file mode 100644 index a9fb8cec76..0000000000 --- a/server/src/interfaces/stack.interface.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Updateable } from 'kysely'; -import { StackEntity } from 'src/entities/stack.entity'; - -export const IStackRepository = 'IStackRepository'; - -export interface StackSearch { - ownerId: string; - primaryAssetId?: string; -} - -export interface IStackRepository { - search(query: StackSearch): Promise; - create(stack: { ownerId: string; assetIds: string[] }): Promise; - update(id: string, entity: Updateable): Promise; - delete(id: string): Promise; - deleteAll(ids: string[]): Promise; - getById(id: string): Promise; -} diff --git a/server/src/interfaces/storage.interface.ts b/server/src/interfaces/storage.interface.ts deleted file mode 100644 index b304d94fef..0000000000 --- a/server/src/interfaces/storage.interface.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { WatchOptions } from 'chokidar'; -import { Stats } from 'node:fs'; -import { FileReadOptions } from 'node:fs/promises'; -import { Readable, Writable } from 'node:stream'; -import { CrawlOptionsDto, WalkOptionsDto } from 'src/dtos/library.dto'; - -export interface ImmichReadStream { - stream: Readable; - type?: string; - length?: number; -} - -export interface ImmichZipStream extends ImmichReadStream { - addFile: (inputPath: string, filename: string) => void; - finalize: () => Promise; -} - -export interface DiskUsage { - available: number; - free: number; - total: number; -} - -export const IStorageRepository = 'IStorageRepository'; - -export interface WatchEvents { - onReady(): void; - onAdd(path: string): void; - onChange(path: string): void; - onUnlink(path: string): void; - onError(error: Error): void; -} - -export interface IStorageRepository { - createZipStream(): ImmichZipStream; - createReadStream(filepath: string, mimeType?: string | null): Promise; - readFile(filepath: string, options?: FileReadOptions): Promise; - createFile(filepath: string, buffer: Buffer): Promise; - createWriteStream(filepath: string): Writable; - createOrOverwriteFile(filepath: string, buffer: Buffer): Promise; - overwriteFile(filepath: string, buffer: Buffer): Promise; - realpath(filepath: string): Promise; - unlink(filepath: string): Promise; - unlinkDir(folder: string, options?: { recursive?: boolean; force?: boolean }): Promise; - removeEmptyDirs(folder: string, self?: boolean): Promise; - checkFileExists(filepath: string, mode?: number): Promise; - mkdirSync(filepath: string): void; - checkDiskUsage(folder: string): Promise; - readdir(folder: string): Promise; - stat(filepath: string): Promise; - crawl(options: CrawlOptionsDto): Promise; - walk(options: WalkOptionsDto): AsyncGenerator; - copyFile(source: string, target: string): Promise; - rename(source: string, target: string): Promise; - watch(paths: string[], options: WatchOptions, events: Partial): () => Promise; - utimes(filepath: string, atime: Date, mtime: Date): Promise; -} diff --git a/server/src/interfaces/system-metadata.interface.ts b/server/src/interfaces/system-metadata.interface.ts deleted file mode 100644 index fd83d33ee9..0000000000 --- a/server/src/interfaces/system-metadata.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { SystemMetadata } from 'src/entities/system-metadata.entity'; - -export const ISystemMetadataRepository = 'ISystemMetadataRepository'; - -export interface ISystemMetadataRepository { - get(key: T): Promise; - set(key: T, value: SystemMetadata[T]): Promise; - delete(key: T): Promise; - readFile(filename: string): Promise; -} diff --git a/server/src/interfaces/tag.interface.ts b/server/src/interfaces/tag.interface.ts deleted file mode 100644 index 16a34d6ac4..0000000000 --- a/server/src/interfaces/tag.interface.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { TagEntity } from 'src/entities/tag.entity'; -import { IBulkAsset } from 'src/utils/asset.util'; - -export const ITagRepository = 'ITagRepository'; - -export type AssetTagItem = { assetId: string; tagId: string }; - -export interface ITagRepository extends IBulkAsset { - getAll(userId: string): Promise; - getByValue(userId: string, value: string): Promise; - upsertValue(request: { userId: string; value: string; parent?: TagEntity }): Promise; - - create(tag: Partial): Promise; - get(id: string): Promise; - update(tag: { id: string } & Partial): Promise; - delete(id: string): Promise; - - upsertAssetTags({ assetId, tagIds }: { assetId: string; tagIds: string[] }): Promise; - upsertAssetIds(items: AssetTagItem[]): Promise; - deleteEmptyTags(): Promise; -} diff --git a/server/src/interfaces/user.interface.ts b/server/src/interfaces/user.interface.ts deleted file mode 100644 index 6ff3fc824a..0000000000 --- a/server/src/interfaces/user.interface.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Insertable, Updateable } from 'kysely'; -import { Users } from 'src/db'; -import { UserMetadata } from 'src/entities/user-metadata.entity'; -import { UserEntity } from 'src/entities/user.entity'; - -export interface UserListFilter { - withDeleted?: boolean; -} - -export interface UserStatsQueryResponse { - userId: string; - userName: string; - photos: number; - videos: number; - usage: number; - usagePhotos: number; - usageVideos: number; - quotaSizeInBytes: number | null; -} - -export interface UserFindOptions { - withDeleted?: boolean; -} - -export const IUserRepository = 'IUserRepository'; - -export interface IUserRepository { - get(id: string, options: UserFindOptions): Promise; - getAdmin(): Promise; - hasAdmin(): Promise; - getByEmail(email: string, withPassword?: boolean): Promise; - getByStorageLabel(storageLabel: string): Promise; - getByOAuthId(oauthId: string): Promise; - getDeletedUsers(): Promise; - getList(filter?: UserListFilter): Promise; - getUserStats(): Promise; - create(user: Insertable): Promise; - update(id: string, user: Updateable): Promise; - upsertMetadata(id: string, item: { key: T; value: UserMetadata[T] }): Promise; - deleteMetadata(id: string, key: T): Promise; - delete(user: UserEntity, hard?: boolean): Promise; - updateUsage(id: string, delta: number): Promise; - syncUsage(id?: string): Promise; -} diff --git a/server/src/main.ts b/server/src/main.ts index 3097eee69b..95b35c6915 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -13,7 +13,7 @@ if (immichApp) { let apiProcess: ChildProcess | undefined; const onError = (name: string, error: Error) => { - console.error(`${name} worker error: ${error}`); + console.error(`${name} worker error: ${error}, stack: ${error.stack}`); }; const onExit = (name: string, exitCode: number | null) => { diff --git a/server/src/middleware/file-upload.interceptor.ts b/server/src/middleware/file-upload.interceptor.ts index 743764ff75..6f6d9aaf43 100644 --- a/server/src/middleware/file-upload.interceptor.ts +++ b/server/src/middleware/file-upload.interceptor.ts @@ -10,14 +10,10 @@ import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { RouteKey } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; import { LoggingRepository } from 'src/repositories/logging.repository'; -import { AssetMediaService, UploadFile } from 'src/services/asset-media.service'; +import { AssetMediaService } from 'src/services/asset-media.service'; +import { ImmichFile, UploadFile, UploadFiles } from 'src/types'; import { asRequest, mapToUploadFile } from 'src/utils/asset.util'; -export interface UploadFiles { - assetData: ImmichFile[]; - sidecarData: ImmichFile[]; -} - export function getFile(files: UploadFiles, property: 'assetData' | 'sidecarData') { const file = files[property]?.[0]; return file ? mapToUploadFile(file) : file; @@ -30,12 +26,6 @@ export function getFiles(files: UploadFiles) { }; } -export interface ImmichFile extends Express.Multer.File { - /** sha1 hash of file */ - uuid: string; - checksum: Buffer; -} - type DiskStorageCallback = (error: Error | null, result: string) => void; type ImmichMulterFile = Express.Multer.File & { uuid: string }; diff --git a/server/src/middleware/global-exception.filter.ts b/server/src/middleware/global-exception.filter.ts index 7d7ade471e..a8afa91cbc 100644 --- a/server/src/middleware/global-exception.filter.ts +++ b/server/src/middleware/global-exception.filter.ts @@ -22,6 +22,13 @@ export class GlobalExceptionFilter implements ExceptionFilter { } } + handleError(res: Response, error: Error) { + const { status, body } = this.fromError(error); + if (!res.headersSent) { + res.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() }); + } + } + private fromError(error: Error) { logGlobalError(this.logger, error); diff --git a/server/src/migrations/1645130759468-CreateUserTable.ts b/server/src/migrations/1645130759468-CreateUserTable.ts index 6e3d427dd2..1aedfb67d4 100644 --- a/server/src/migrations/1645130759468-CreateUserTable.ts +++ b/server/src/migrations/1645130759468-CreateUserTable.ts @@ -2,6 +2,7 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class CreateUserTable1645130759468 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`); await queryRunner.query(` create table if not exists users ( diff --git a/server/src/migrations/1700713994428-AddCLIPEmbeddingIndex.ts b/server/src/migrations/1700713994428-AddCLIPEmbeddingIndex.ts index f9ea5a0dc3..993e12f822 100644 --- a/server/src/migrations/1700713994428-AddCLIPEmbeddingIndex.ts +++ b/server/src/migrations/1700713994428-AddCLIPEmbeddingIndex.ts @@ -1,4 +1,4 @@ -import { DatabaseExtension } from 'src/interfaces/database.interface'; +import { DatabaseExtension } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { MigrationInterface, QueryRunner } from 'typeorm'; diff --git a/server/src/migrations/1700714033632-AddFaceEmbeddingIndex.ts b/server/src/migrations/1700714033632-AddFaceEmbeddingIndex.ts index d11e7b921e..182aae4e42 100644 --- a/server/src/migrations/1700714033632-AddFaceEmbeddingIndex.ts +++ b/server/src/migrations/1700714033632-AddFaceEmbeddingIndex.ts @@ -1,4 +1,4 @@ -import { DatabaseExtension } from 'src/interfaces/database.interface'; +import { DatabaseExtension } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { MigrationInterface, QueryRunner } from 'typeorm'; diff --git a/server/src/migrations/1718486162779-AddFaceSearchRelation.ts b/server/src/migrations/1718486162779-AddFaceSearchRelation.ts index ae6d752c65..e08bcb8e25 100644 --- a/server/src/migrations/1718486162779-AddFaceSearchRelation.ts +++ b/server/src/migrations/1718486162779-AddFaceSearchRelation.ts @@ -1,4 +1,4 @@ -import { DatabaseExtension } from 'src/interfaces/database.interface'; +import { DatabaseExtension } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { MigrationInterface, QueryRunner } from 'typeorm'; diff --git a/server/src/migrations/1734574016301-AddTimeBucketIndices.ts b/server/src/migrations/1734574016301-AddTimeBucketIndices.ts index 71e085ee18..2162a713fc 100644 --- a/server/src/migrations/1734574016301-AddTimeBucketIndices.ts +++ b/server/src/migrations/1734574016301-AddTimeBucketIndices.ts @@ -3,10 +3,10 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class AddTimeBucketIndices1734574016301 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( - `CREATE INDEX idx_local_date_time_month ON public.assets ((date_trunc('MONTH', "localDateTime" at time zone 'UTC') at time zone 'UTC'))`, + `CREATE INDEX idx_local_date_time_month ON assets ((date_trunc('MONTH', "localDateTime" at time zone 'UTC') at time zone 'UTC'))`, ); await queryRunner.query( - `CREATE INDEX idx_local_date_time ON public.assets ((("localDateTime" at time zone 'UTC')::date))`, + `CREATE INDEX idx_local_date_time ON assets ((("localDateTime" at time zone 'UTC')::date))`, ); await queryRunner.query(`DROP INDEX "IDX_day_of_month"`); await queryRunner.query(`DROP INDEX "IDX_month"`); diff --git a/server/src/migrations/1734879118272-AddIsFavoritePerson.ts b/server/src/migrations/1734879118272-AddIsFavoritePerson.ts new file mode 100644 index 0000000000..6f7640f96f --- /dev/null +++ b/server/src/migrations/1734879118272-AddIsFavoritePerson.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddIsFavoritePerson1734879118272 implements MigrationInterface { + name = 'AddIsFavoritePerson1734879118272' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "person" ADD "isFavorite" boolean NOT NULL DEFAULT false`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "isFavorite"`); + } + +} diff --git a/server/src/migrations/1737845696644-NullableDates.ts b/server/src/migrations/1737845696644-NullableDates.ts new file mode 100644 index 0000000000..8a08b985c5 --- /dev/null +++ b/server/src/migrations/1737845696644-NullableDates.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class NullableDates1737845696644 implements MigrationInterface { + name = 'NullableDates1737845696644' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "fileCreatedAt" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "localDateTime" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "fileModifiedAt" DROP NOT NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "fileModifiedAt" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "localDateTime" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "fileCreatedAt" SET NOT NULL`); + } + +} diff --git a/server/src/migrations/1738889177573-AddPersonColor.ts b/server/src/migrations/1738889177573-AddPersonColor.ts new file mode 100644 index 0000000000..ebdc86f52d --- /dev/null +++ b/server/src/migrations/1738889177573-AddPersonColor.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddPersonColor1738889177573 implements MigrationInterface { + name = 'AddPersonColor1738889177573' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "person" ADD "color" character varying`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "color"`); + } + +} diff --git a/server/src/migrations/1739466714036-AddDeletedAtColumnToAssetFacesTable.ts b/server/src/migrations/1739466714036-AddDeletedAtColumnToAssetFacesTable.ts new file mode 100644 index 0000000000..e6f18e2618 --- /dev/null +++ b/server/src/migrations/1739466714036-AddDeletedAtColumnToAssetFacesTable.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddDeletedAtColumnToAssetFacesTable1739466714036 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE asset_faces + ADD COLUMN "deletedAt" TIMESTAMP WITH TIME ZONE DEFAULT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE asset_faces + DROP COLUMN "deletedAt" + `); + } +} diff --git a/server/src/migrations/1739824470990-AddMemoryShowHideDates.ts b/server/src/migrations/1739824470990-AddMemoryShowHideDates.ts new file mode 100644 index 0000000000..d53c7c17f6 --- /dev/null +++ b/server/src/migrations/1739824470990-AddMemoryShowHideDates.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddMemoryShowHideDates1739824470990 implements MigrationInterface { + name = 'AddMemoryShowHideDates1739824470990' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "memories" ADD "showAt" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`ALTER TABLE "memories" ADD "hideAt" TIMESTAMP WITH TIME ZONE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "memories" DROP COLUMN "hideAt"`); + await queryRunner.query(`ALTER TABLE "memories" DROP COLUMN "showAt"`); + } + +} diff --git a/server/src/migrations/1740001232576-AddSessionSyncCheckpointTable.ts b/server/src/migrations/1740001232576-AddSessionSyncCheckpointTable.ts new file mode 100644 index 0000000000..ef75dd7c0d --- /dev/null +++ b/server/src/migrations/1740001232576-AddSessionSyncCheckpointTable.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddSessionSyncCheckpointTable1740001232576 implements MigrationInterface { + name = 'AddSessionSyncCheckpointTable1740001232576' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "session_sync_checkpoints" ("sessionId" uuid NOT NULL, "type" character varying NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "ack" character varying NOT NULL, CONSTRAINT "PK_b846ab547a702863ef7cd9412fb" PRIMARY KEY ("sessionId", "type"))`); + await queryRunner.query(`ALTER TABLE "session_sync_checkpoints" ADD CONSTRAINT "FK_d8ddd9d687816cc490432b3d4bc" FOREIGN KEY ("sessionId") REFERENCES "sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(` + create trigger session_sync_checkpoints_updated_at + before update on session_sync_checkpoints + for each row execute procedure updated_at() + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`drop trigger session_sync_checkpoints_updated_at on session_sync_checkpoints`); + await queryRunner.query(`ALTER TABLE "session_sync_checkpoints" DROP CONSTRAINT "FK_d8ddd9d687816cc490432b3d4bc"`); + await queryRunner.query(`DROP TABLE "session_sync_checkpoints"`); + } + +} diff --git a/server/src/migrations/1740064899123-AddUsersAuditTable.ts b/server/src/migrations/1740064899123-AddUsersAuditTable.ts new file mode 100644 index 0000000000..b8f2ce5e3a --- /dev/null +++ b/server/src/migrations/1740064899123-AddUsersAuditTable.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddUsersAuditTable1740064899123 implements MigrationInterface { + name = 'AddUsersAuditTable1740064899123' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_users_updated_at_asc_id_asc" ON "users" ("updatedAt" ASC, "id" ASC);`) + await queryRunner.query(`CREATE TABLE "users_audit" ("id" SERIAL NOT NULL, "userId" uuid NOT NULL, "deletedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_e9b2bdfd90e7eb5961091175180" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_users_audit_deleted_at_asc_user_id_asc" ON "users_audit" ("deletedAt" ASC, "userId" ASC);`) + await queryRunner.query(`CREATE OR REPLACE FUNCTION users_delete_audit() RETURNS TRIGGER AS + $$ + BEGIN + INSERT INTO users_audit ("userId") + SELECT "id" + FROM OLD; + RETURN NULL; + END; + $$ LANGUAGE plpgsql` + ); + await queryRunner.query(`CREATE OR REPLACE TRIGGER users_delete_audit + AFTER DELETE ON users + REFERENCING OLD TABLE AS OLD + FOR EACH STATEMENT + EXECUTE FUNCTION users_delete_audit(); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TRIGGER users_delete_audit`); + await queryRunner.query(`DROP FUNCTION users_delete_audit`); + await queryRunner.query(`DROP TABLE "users_audit"`); + } + +} diff --git a/server/src/migrations/1740586617223-AddUpdateIdColumns.ts b/server/src/migrations/1740586617223-AddUpdateIdColumns.ts new file mode 100644 index 0000000000..02d680ddf6 --- /dev/null +++ b/server/src/migrations/1740586617223-AddUpdateIdColumns.ts @@ -0,0 +1,134 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddUpdateIdColumns1740586617223 implements MigrationInterface { + name = 'AddUpdateIdColumns1740586617223' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + create or replace function immich_uuid_v7(p_timestamp timestamp with time zone default clock_timestamp()) + returns uuid + as $$ + select encode( + set_bit( + set_bit( + overlay(uuid_send(gen_random_uuid()) + placing substring(int8send(floor(extract(epoch from p_timestamp) * 1000)::bigint) from 3) + from 1 for 6 + ), + 52, 1 + ), + 53, 1 + ), + 'hex')::uuid; + $$ + language SQL + volatile; + `) + await queryRunner.query(` + CREATE OR REPLACE FUNCTION updated_at() RETURNS TRIGGER + LANGUAGE plpgsql + as $$ + BEGIN + return new; + END; + $$; + `) + await queryRunner.query(`ALTER TABLE "person" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "asset_files" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "libraries" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "tags" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "assets" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "users" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "albums" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "sessions" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "session_sync_checkpoints" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "partners" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "memories" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "api_keys" ADD "updateId" uuid`); + await queryRunner.query(`ALTER TABLE "activity" ADD "updateId" uuid`); + + await queryRunner.query(`UPDATE "person" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "asset_files" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "libraries" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "tags" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "assets" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "users" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "albums" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "sessions" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "session_sync_checkpoints" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "partners" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "memories" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "api_keys" SET "updateId" = immich_uuid_v7("updatedAt")`); + await queryRunner.query(`UPDATE "activity" SET "updateId" = immich_uuid_v7("updatedAt")`); + + await queryRunner.query(`ALTER TABLE "person" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "asset_files" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "libraries" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "tags" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "albums" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "sessions" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "session_sync_checkpoints" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "partners" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "memories" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "api_keys" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "activity" ALTER COLUMN "updateId" SET NOT NULL, ALTER COLUMN "updateId" SET DEFAULT immich_uuid_v7()`); + + await queryRunner.query(`CREATE INDEX "IDX_person_update_id" ON "person" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_asset_files_update_id" ON "asset_files" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_libraries_update_id" ON "libraries" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_tags_update_id" ON "tags" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_assets_update_id" ON "assets" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_users_update_id" ON "users" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_albums_update_id" ON "albums" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_sessions_update_id" ON "sessions" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_session_sync_checkpoints_update_id" ON "session_sync_checkpoints" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_partners_update_id" ON "partners" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_memories_update_id" ON "memories" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_api_keys_update_id" ON "api_keys" ("updateId")`); + await queryRunner.query(`CREATE INDEX "IDX_activity_update_id" ON "activity" ("updateId")`); + + await queryRunner.query(` + CREATE OR REPLACE FUNCTION updated_at() RETURNS TRIGGER + LANGUAGE plpgsql + as $$ + DECLARE + clock_timestamp TIMESTAMP := clock_timestamp(); + BEGIN + new."updatedAt" = clock_timestamp; + new."updateId" = immich_uuid_v7(clock_timestamp); + return new; + END; + $$; + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "activity" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "api_keys" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "memories" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "partners" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "session_sync_checkpoints" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "sessions" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "albums" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "libraries" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "asset_files" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "updateId"`); + await queryRunner.query(`DROP FUNCTION immich_uuid_v7`); + await queryRunner.query(` + CREATE OR REPLACE FUNCTION updated_at() RETURNS TRIGGER + LANGUAGE plpgsql + as $$ + BEGIN + new."updatedAt" = now(); + return new; + END; + $$; + `) + } + +} diff --git a/server/src/migrations/1740595460866-UsersAuditUuidv7PrimaryKey.ts b/server/src/migrations/1740595460866-UsersAuditUuidv7PrimaryKey.ts new file mode 100644 index 0000000000..59fc4dbd5b --- /dev/null +++ b/server/src/migrations/1740595460866-UsersAuditUuidv7PrimaryKey.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UsersAuditUuidv7PrimaryKey1740595460866 implements MigrationInterface { + name = 'UsersAuditUuidv7PrimaryKey1740595460866' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_users_audit_deleted_at_asc_user_id_asc"`); + await queryRunner.query(`ALTER TABLE "users_audit" DROP CONSTRAINT "PK_e9b2bdfd90e7eb5961091175180"`); + await queryRunner.query(`ALTER TABLE "users_audit" DROP COLUMN "id"`); + await queryRunner.query(`ALTER TABLE "users_audit" ADD "id" uuid NOT NULL DEFAULT immich_uuid_v7()`); + await queryRunner.query(`ALTER TABLE "users_audit" ADD CONSTRAINT "PK_e9b2bdfd90e7eb5961091175180" PRIMARY KEY ("id")`); + await queryRunner.query(`ALTER TABLE "users_audit" ALTER COLUMN "deletedAt" SET DEFAULT clock_timestamp()`) + await queryRunner.query(`CREATE INDEX "IDX_users_audit_deleted_at" ON "users_audit" ("deletedAt")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_users_audit_deleted_at"`); + await queryRunner.query(`ALTER TABLE "users_audit" DROP CONSTRAINT "PK_e9b2bdfd90e7eb5961091175180"`); + await queryRunner.query(`ALTER TABLE "users_audit" DROP COLUMN "id"`); + await queryRunner.query(`ALTER TABLE "users_audit" ADD "id" SERIAL NOT NULL`); + await queryRunner.query(`ALTER TABLE "users_audit" ADD CONSTRAINT "PK_e9b2bdfd90e7eb5961091175180" PRIMARY KEY ("id")`); + await queryRunner.query(`ALTER TABLE "users_audit" ALTER COLUMN "deletedAt" SET DEFAULT now()`); + await queryRunner.query(`CREATE INDEX "IDX_users_audit_deleted_at_asc_user_id_asc" ON "users_audit" ("userId", "deletedAt") `); + } + +} diff --git a/server/src/migrations/1740619600996-AddManualSourceType.ts b/server/src/migrations/1740619600996-AddManualSourceType.ts new file mode 100644 index 0000000000..dd53312ad7 --- /dev/null +++ b/server/src/migrations/1740619600996-AddManualSourceType.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddManualSourceType1740619600996 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TYPE sourceType ADD VALUE 'manual'`); + } + + public async down(queryRunner: QueryRunner): Promise { + // Prior to this migration, manually tagged pictures had the 'machine-learning' type + await queryRunner.query( + `UPDATE "asset_faces" SET "sourceType" = 'machine-learning' WHERE "sourceType" = 'manual';`, + ); + + // Postgres doesn't allow removing values from enums, we have to recreate the type + await queryRunner.query(`ALTER TYPE sourceType RENAME TO oldSourceType`); + await queryRunner.query(`CREATE TYPE sourceType AS ENUM ('machine-learning', 'exif');`); + + await queryRunner.query(`ALTER TABLE "asset_faces" ALTER COLUMN "sourceType" DROP DEFAULT;`); + await queryRunner.query( + `ALTER TABLE "asset_faces" ALTER COLUMN "sourceType" TYPE sourceType USING "sourceType"::text::sourceType;`, + ); + await queryRunner.query( + `ALTER TABLE "asset_faces" ALTER COLUMN "sourceType" SET DEFAULT 'machine-learning'::sourceType;`, + ); + await queryRunner.query(`DROP TYPE oldSourceType;`); + } +} diff --git a/server/src/migrations/1740654480319-UnsetStackedAssetsFromDuplicateStatus.ts b/server/src/migrations/1740654480319-UnsetStackedAssetsFromDuplicateStatus.ts new file mode 100644 index 0000000000..5c735a60bb --- /dev/null +++ b/server/src/migrations/1740654480319-UnsetStackedAssetsFromDuplicateStatus.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UnsetStackedAssetsFromDuplicateStatus1740654480319 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + update assets + set "duplicateId" = null + where "stackId" is not null`); + } + + public async down(): Promise { + // No need to revert this migration + } +} diff --git a/server/src/migrations/1740739778549-CreatePartnersAuditTable.ts b/server/src/migrations/1740739778549-CreatePartnersAuditTable.ts new file mode 100644 index 0000000000..d9c9dc1949 --- /dev/null +++ b/server/src/migrations/1740739778549-CreatePartnersAuditTable.ts @@ -0,0 +1,38 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreatePartnersAuditTable1740739778549 implements MigrationInterface { + name = 'CreatePartnersAuditTable1740739778549' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "partners_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "sharedById" uuid NOT NULL, "sharedWithId" uuid NOT NULL, "deletedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT clock_timestamp(), CONSTRAINT "PK_952b50217ff78198a7e380f0359" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_partners_audit_shared_by_id" ON "partners_audit" ("sharedById") `); + await queryRunner.query(`CREATE INDEX "IDX_partners_audit_shared_with_id" ON "partners_audit" ("sharedWithId") `); + await queryRunner.query(`CREATE INDEX "IDX_partners_audit_deleted_at" ON "partners_audit" ("deletedAt") `); + await queryRunner.query(`CREATE OR REPLACE FUNCTION partners_delete_audit() RETURNS TRIGGER AS + $$ + BEGIN + INSERT INTO partners_audit ("sharedById", "sharedWithId") + SELECT "sharedById", "sharedWithId" + FROM OLD; + RETURN NULL; + END; + $$ LANGUAGE plpgsql` + ); + await queryRunner.query(`CREATE OR REPLACE TRIGGER partners_delete_audit + AFTER DELETE ON partners + REFERENCING OLD TABLE AS OLD + FOR EACH STATEMENT + EXECUTE FUNCTION partners_delete_audit(); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_partners_audit_deleted_at"`); + await queryRunner.query(`DROP INDEX "public"."IDX_partners_audit_shared_with_id"`); + await queryRunner.query(`DROP INDEX "public"."IDX_partners_audit_shared_by_id"`); + await queryRunner.query(`DROP TRIGGER partners_delete_audit`); + await queryRunner.query(`DROP FUNCTION partners_delete_audit`); + await queryRunner.query(`DROP TABLE "partners_audit"`); + } + +} diff --git a/server/src/migrations/1741027685381-ResetMemories.ts b/server/src/migrations/1741027685381-ResetMemories.ts new file mode 100644 index 0000000000..6a80372219 --- /dev/null +++ b/server/src/migrations/1741027685381-ResetMemories.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ResetMemories1741027685381 implements MigrationInterface { + name = 'ResetMemories1741027685381'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM "memories"`); + await queryRunner.query(`DELETE FROM "system_metadata" WHERE "key" = 'memories-state'`); + } + + public async down(): Promise { + // nothing to do + } +} diff --git a/server/src/migrations/1741179334403-MoveHistoryUuidEntityId.ts b/server/src/migrations/1741179334403-MoveHistoryUuidEntityId.ts new file mode 100644 index 0000000000..449272341c --- /dev/null +++ b/server/src/migrations/1741179334403-MoveHistoryUuidEntityId.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class MoveHistoryUuidEntityId1741179334403 implements MigrationInterface { + name = 'MoveHistoryUuidEntityId1741179334403'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "move_history" ALTER COLUMN "entityId" TYPE uuid USING "entityId"::uuid;`); + await queryRunner.query(`delete from "move_history" + where + "move_history"."entityId" not in ( + select + "id" + from + "assets" + where + "assets"."id" = "move_history"."entityId" + ) + and "move_history"."pathType" = 'original' + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "move_history" ALTER COLUMN "entityId" TYPE character varying`); + } +} + diff --git a/server/src/migrations/1741191762113-AssetAuditTable.ts b/server/src/migrations/1741191762113-AssetAuditTable.ts new file mode 100644 index 0000000000..c02408c384 --- /dev/null +++ b/server/src/migrations/1741191762113-AssetAuditTable.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AssetAuditTable1741191762113 implements MigrationInterface { + name = 'AssetAuditTable1741191762113' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "assets_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "assetId" uuid NOT NULL, "ownerId" uuid NOT NULL, "deletedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT clock_timestamp(), CONSTRAINT "PK_99bd5c015f81a641927a32b4212" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_assets_audit_asset_id" ON "assets_audit" ("assetId") `); + await queryRunner.query(`CREATE INDEX "IDX_assets_audit_owner_id" ON "assets_audit" ("ownerId") `); + await queryRunner.query(`CREATE INDEX "IDX_assets_audit_deleted_at" ON "assets_audit" ("deletedAt") `); + await queryRunner.query(`CREATE OR REPLACE FUNCTION assets_delete_audit() RETURNS TRIGGER AS + $$ + BEGIN + INSERT INTO assets_audit ("assetId", "ownerId") + SELECT "id", "ownerId" + FROM OLD; + RETURN NULL; + END; + $$ LANGUAGE plpgsql` + ); + await queryRunner.query(`CREATE OR REPLACE TRIGGER assets_delete_audit + AFTER DELETE ON assets + REFERENCING OLD TABLE AS OLD + FOR EACH STATEMENT + EXECUTE FUNCTION assets_delete_audit(); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TRIGGER assets_delete_audit`); + await queryRunner.query(`DROP FUNCTION assets_delete_audit`); + await queryRunner.query(`DROP INDEX "IDX_assets_audit_deleted_at"`); + await queryRunner.query(`DROP INDEX "IDX_assets_audit_owner_id"`); + await queryRunner.query(`DROP INDEX "IDX_assets_audit_asset_id"`); + await queryRunner.query(`DROP TABLE "assets_audit"`); + } +} diff --git a/server/src/migrations/1741280328985-FixAssetAndUserCascadeConditions.ts b/server/src/migrations/1741280328985-FixAssetAndUserCascadeConditions.ts new file mode 100644 index 0000000000..20215c1b59 --- /dev/null +++ b/server/src/migrations/1741280328985-FixAssetAndUserCascadeConditions.ts @@ -0,0 +1,50 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class FixAssetAndUserCascadeConditions1741280328985 implements MigrationInterface { + name = 'FixAssetAndUserCascadeConditions1741280328985'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE OR REPLACE TRIGGER assets_delete_audit + AFTER DELETE ON assets + REFERENCING OLD TABLE AS OLD + FOR EACH STATEMENT + WHEN (pg_trigger_depth() = 0) + EXECUTE FUNCTION assets_delete_audit();`); + await queryRunner.query(` + CREATE OR REPLACE TRIGGER users_delete_audit + AFTER DELETE ON users + REFERENCING OLD TABLE AS OLD + FOR EACH STATEMENT + WHEN (pg_trigger_depth() = 0) + EXECUTE FUNCTION users_delete_audit();`); + await queryRunner.query(` + CREATE OR REPLACE TRIGGER partners_delete_audit + AFTER DELETE ON partners + REFERENCING OLD TABLE AS OLD + FOR EACH STATEMENT + WHEN (pg_trigger_depth() = 0) + EXECUTE FUNCTION partners_delete_audit();`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE OR REPLACE TRIGGER assets_delete_audit + AFTER DELETE ON assets + REFERENCING OLD TABLE AS OLD + FOR EACH STATEMENT + EXECUTE FUNCTION assets_delete_audit();`); + await queryRunner.query(` + CREATE OR REPLACE TRIGGER users_delete_audit + AFTER DELETE ON users + REFERENCING OLD TABLE AS OLD + FOR EACH STATEMENT + EXECUTE FUNCTION users_delete_audit();`); + await queryRunner.query(` + CREATE OR REPLACE TRIGGER partners_delete_audit + AFTER DELETE ON partners + REFERENCING OLD TABLE AS OLD + FOR EACH STATEMENT + EXECUTE FUNCTION partners_delete_audit();`); + } +} diff --git a/server/src/migrations/1741281344519-AddExifUpdateId.ts b/server/src/migrations/1741281344519-AddExifUpdateId.ts new file mode 100644 index 0000000000..eb32836a1d --- /dev/null +++ b/server/src/migrations/1741281344519-AddExifUpdateId.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddExifUpdateId1741281344519 implements MigrationInterface { + name = 'AddExifUpdateId1741281344519'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "exif" ADD "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT clock_timestamp()`, + ); + await queryRunner.query(`ALTER TABLE "exif" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7()`); + await queryRunner.query(`CREATE INDEX "IDX_asset_exif_update_id" ON "exif" ("updateId") `); + await queryRunner.query(` + create trigger asset_exif_updated_at + before update on exif + for each row execute procedure updated_at() + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_asset_exif_update_id"`); + await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "updateId"`); + await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "updatedAt"`); + await queryRunner.query(`DROP TRIGGER asset_exif_updated_at on exif`); + } +} diff --git a/server/src/queries/activity.repository.sql b/server/src/queries/activity.repository.sql index 8e9bb11f25..0ddb91c692 100644 --- a/server/src/queries/activity.repository.sql +++ b/server/src/queries/activity.repository.sql @@ -43,3 +43,6 @@ where and "activity"."albumId" = $2 and "activity"."isLiked" = $3 and "assets"."deletedAt" is null + and "assets"."fileCreatedAt" is not null + and "assets"."fileModifiedAt" is not null + and "assets"."localDateTime" is not null diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index 8f17146633..dbf27866be 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -90,7 +90,7 @@ select ( select "assets".*, - to_json("exif") as "exifInfo" + "exif" as "exifInfo" from "assets" inner join "exif" on "assets"."id" = "exif"."assetId" @@ -98,7 +98,6 @@ select where "albums_assets_assets"."albumsId" = "albums"."id" and "assets"."deletedAt" is null - and "assets"."isArchived" = $1 order by "assets"."fileCreatedAt" desc ) as "asset" @@ -106,7 +105,7 @@ select from "albums" where - "albums"."id" = $2 + "albums"."id" = $1 and "albums"."deletedAt" is null -- AlbumRepository.getByAssetId @@ -181,19 +180,20 @@ select ) as "albumUsers" from "albums" - left join "albums_assets_assets" as "album_assets" on "album_assets"."albumsId" = "albums"."id" - left join "albums_shared_users_users" as "album_users" on "album_users"."albumsId" = "albums"."id" + inner join "albums_assets_assets" as "album_assets" on "album_assets"."albumsId" = "albums"."id" where ( - ( - "albums"."ownerId" = $1 - and "album_assets"."assetsId" = $2 - ) - or ( - "album_users"."usersId" = $3 - and "album_assets"."assetsId" = $4 + "albums"."ownerId" = $1 + or exists ( + select + from + "albums_shared_users_users" as "album_users" + where + "album_users"."albumsId" = "albums"."id" + and "album_users"."usersId" = $2 ) ) + and "album_assets"."assetsId" = $3 and "albums"."deletedAt" is null order by "albums"."createdAt" desc, @@ -201,16 +201,17 @@ order by -- AlbumRepository.getMetadataForIds select - "albums"."id", - min("assets"."fileCreatedAt") as "startDate", - max("assets"."fileCreatedAt") as "endDate", - count("assets"."id") as "assetCount" + "albums"."id" as "albumId", + min("assets"."localDateTime") as "startDate", + max("assets"."localDateTime") as "endDate", + count("assets"."id")::int as "assetCount" from "albums" - left join "albums_assets_assets" as "album_assets" on "album_assets"."albumsId" = "albums"."id" - left join "assets" on "assets"."id" = "album_assets"."assetsId" + inner join "albums_assets_assets" as "album_assets" on "album_assets"."albumsId" = "albums"."id" + inner join "assets" on "assets"."id" = "album_assets"."assetsId" where "albums"."id" in ($1) + and "assets"."deletedAt" is null group by "albums"."id" @@ -306,8 +307,8 @@ order by "albums"."createdAt" desc -- AlbumRepository.getShared -select distinct - on ("albums"."createdAt") "albums".*, +select + "albums".*, ( select coalesce(json_agg(agg), '[]') @@ -390,15 +391,26 @@ select distinct ) as "sharedLinks" from "albums" - left join "albums_shared_users_users" as "shared_albums" on "shared_albums"."albumsId" = "albums"."id" - left join "shared_links" on "shared_links"."albumId" = "albums"."id" where ( - "shared_albums"."usersId" = $1 - or "shared_links"."userId" = $2 - or ( - "albums"."ownerId" = $3 - and "shared_albums"."usersId" is not null + exists ( + select + from + "albums_shared_users_users" as "album_users" + where + "album_users"."albumsId" = "albums"."id" + and ( + "albums"."ownerId" = $1 + or "album_users"."usersId" = $2 + ) + ) + or exists ( + select + from + "shared_links" + where + "shared_links"."albumId" = "albums"."id" + and "shared_links"."userId" = $3 ) ) and "albums"."deletedAt" is null @@ -406,48 +418,8 @@ order by "albums"."createdAt" desc -- AlbumRepository.getNotShared -select distinct - on ("albums"."createdAt") "albums".*, - ( - select - coalesce(json_agg(agg), '[]') - from - ( - select - "album_users".*, - ( - select - to_json(obj) - from - ( - select - "id", - "email", - "createdAt", - "profileImagePath", - "isAdmin", - "shouldChangePassword", - "deletedAt", - "oauthId", - "updatedAt", - "storageLabel", - "name", - "quotaSizeInBytes", - "quotaUsageInBytes", - "status", - "profileChangedAt" - from - "users" - where - "users"."id" = "album_users"."usersId" - ) as obj - ) as "user" - from - "albums_shared_users_users" as "album_users" - where - "album_users"."albumsId" = "albums"."id" - ) as agg - ) as "albumUsers", +select + "albums".*, ( select to_json(obj) @@ -474,29 +446,26 @@ select distinct where "users"."id" = "albums"."ownerId" ) as obj - ) as "owner", - ( - select - coalesce(json_agg(agg), '[]') - from - ( - select - * - from - "shared_links" - where - "shared_links"."albumId" = "albums"."id" - ) as agg - ) as "sharedLinks" + ) as "owner" from "albums" - left join "albums_shared_users_users" as "shared_albums" on "shared_albums"."albumsId" = "albums"."id" - left join "shared_links" on "shared_links"."albumId" = "albums"."id" where "albums"."ownerId" = $1 - and "shared_albums"."usersId" is null - and "shared_links"."userId" is null and "albums"."deletedAt" is null + and not exists ( + select + from + "albums_shared_users_users" as "album_users" + where + "album_users"."albumsId" = "albums"."id" + ) + and not exists ( + select + from + "shared_links" + where + "shared_links"."albumId" = "albums"."id" + ) order by "albums"."createdAt" desc diff --git a/server/src/queries/api.key.repository.sql b/server/src/queries/api.key.repository.sql index e1ed8a3dd6..35fd5d2821 100644 --- a/server/src/queries/api.key.repository.sql +++ b/server/src/queries/api.key.repository.sql @@ -3,29 +3,28 @@ -- ApiKeyRepository.getKey select "api_keys"."id", - "api_keys"."key", - "api_keys"."userId", "api_keys"."permissions", - to_json("user") as "user" -from - "api_keys" - inner join lateral ( + ( select - "users".*, + to_json(obj) + from ( select - array_agg("user_metadata") as "metadata" + "users"."id", + "users"."name", + "users"."email", + "users"."isAdmin", + "users"."quotaUsageInBytes", + "users"."quotaSizeInBytes" from - "user_metadata" + "users" where - "users"."id" = "user_metadata"."userId" - ) as "metadata" - from - "users" - where - "users"."id" = "api_keys"."userId" - and "users"."deletedAt" is null - ) as "user" on true + "users"."id" = "api_keys"."userId" + and "users"."deletedAt" is null + ) as obj + ) as "user" +from + "api_keys" where "api_keys"."key" = $1 diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 67f9f39c84..1fd8f55f31 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -47,15 +47,18 @@ with and "asset_files"."type" = $6 ) and "assets"."deletedAt" is null + order by + (assets."localDateTime" at time zone 'UTC')::date desc limit $7 ) as "a" on true inner join "exif" on "a"."id" = "exif"."assetId" ) select - ( - (now() at time zone 'UTC')::date - ("localDateTime" at time zone 'UTC')::date - ) / 365 as "yearsAgo", + date_part( + 'year', + ("localDateTime" at time zone 'UTC')::date + )::int as "year", json_agg("res") as "assets" from "res" @@ -94,6 +97,7 @@ select left join "person" on "person"."id" = "asset_faces"."personId" where "asset_faces"."assetId" = "assets"."id" + and "asset_faces"."deletedAt" is null ) as "faces", ( select @@ -157,6 +161,9 @@ where "ownerId" = $1::uuid and "deviceId" = $2 and "isVisible" = $3 + and "assets"."fileCreatedAt" is not null + and "assets"."fileModifiedAt" is not null + and "assets"."localDateTime" is not null and "deletedAt" is null -- AssetRepository.getLivePhotoCount @@ -258,6 +265,9 @@ with where "assets"."deletedAt" is null and "assets"."isVisible" = $2 + and "assets"."fileCreatedAt" is not null + and "assets"."fileModifiedAt" is not null + and "assets"."localDateTime" is not null ) select "timeBucket", @@ -306,17 +316,27 @@ order by with "duplicates" as ( select - "duplicateId", - jsonb_agg("assets") as "assets" + "assets"."duplicateId", + jsonb_agg("asset") as "assets" from "assets" + left join lateral ( + select + "assets".*, + "exif" as "exifInfo" + from + "exif" + where + "exif"."assetId" = "assets"."id" + ) as "asset" on true where - "ownerId" = $1::uuid - and "duplicateId" is not null - and "deletedAt" is null - and "isVisible" = $2 + "assets"."ownerId" = $1::uuid + and "assets"."duplicateId" is not null + and "assets"."deletedAt" is null + and "assets"."isVisible" = $2 + and "assets"."stackId" is null group by - "duplicateId" + "assets"."duplicateId" ), "unique" as ( select @@ -400,8 +420,8 @@ from ) as "stacked_assets" on "asset_stack"."id" is not null where "assets"."ownerId" = $1::uuid - and "isVisible" = $2 - and "updatedAt" <= $3 + and "assets"."isVisible" = $2 + and "assets"."updatedAt" <= $3 and "assets"."id" > $4 order by "assets"."id" @@ -430,7 +450,7 @@ from ) as "stacked_assets" on "asset_stack"."id" is not null where "assets"."ownerId" = any ($1::uuid[]) - and "isVisible" = $2 - and "updatedAt" > $3 + and "assets"."isVisible" = $2 + and "assets"."updatedAt" > $3 limit $4 diff --git a/server/src/queries/database.repository.sql b/server/src/queries/database.repository.sql new file mode 100644 index 0000000000..8c87a7470f --- /dev/null +++ b/server/src/queries/database.repository.sql @@ -0,0 +1,21 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- DatabaseRepository.getExtensionVersion +SELECT + default_version as "availableVersion", + installed_version as "installedVersion" +FROM + pg_available_extensions +WHERE + name = $1 + +-- DatabaseRepository.getPostgresVersion +SHOW server_version + +-- DatabaseRepository.shouldReindex +SELECT + idx_status +FROM + pg_vector_index_stat +WHERE + indexname = $1 diff --git a/server/src/queries/library.repository.sql b/server/src/queries/library.repository.sql index b0b20fd8a2..43500a8748 100644 --- a/server/src/queries/library.repository.sql +++ b/server/src/queries/library.repository.sql @@ -2,34 +2,7 @@ -- LibraryRepository.get select - "libraries".*, - ( - select - to_json(obj) - from - ( - select - "users"."id", - "users"."email", - "users"."createdAt", - "users"."profileImagePath", - "users"."isAdmin", - "users"."shouldChangePassword", - "users"."deletedAt", - "users"."oauthId", - "users"."updatedAt", - "users"."storageLabel", - "users"."name", - "users"."quotaSizeInBytes", - "users"."quotaUsageInBytes", - "users"."status", - "users"."profileChangedAt" - from - "users" - where - "users"."id" = "libraries"."ownerId" - ) as obj - ) as "owner" + "libraries".* from "libraries" where @@ -38,34 +11,7 @@ where -- LibraryRepository.getAll select - "libraries".*, - ( - select - to_json(obj) - from - ( - select - "users"."id", - "users"."email", - "users"."createdAt", - "users"."profileImagePath", - "users"."isAdmin", - "users"."shouldChangePassword", - "users"."deletedAt", - "users"."oauthId", - "users"."updatedAt", - "users"."storageLabel", - "users"."name", - "users"."quotaSizeInBytes", - "users"."quotaUsageInBytes", - "users"."status", - "users"."profileChangedAt" - from - "users" - where - "users"."id" = "libraries"."ownerId" - ) as obj - ) as "owner" + "libraries".* from "libraries" where @@ -75,34 +21,7 @@ order by -- LibraryRepository.getAllDeleted select - "libraries".*, - ( - select - to_json(obj) - from - ( - select - "users"."id", - "users"."email", - "users"."createdAt", - "users"."profileImagePath", - "users"."isAdmin", - "users"."shouldChangePassword", - "users"."deletedAt", - "users"."oauthId", - "users"."updatedAt", - "users"."storageLabel", - "users"."name", - "users"."quotaSizeInBytes", - "users"."quotaUsageInBytes", - "users"."status", - "users"."profileChangedAt" - from - "users" - where - "users"."id" = "libraries"."ownerId" - ) as obj - ) as "owner" + "libraries".* from "libraries" where diff --git a/server/src/queries/map.repository.sql b/server/src/queries/map.repository.sql new file mode 100644 index 0000000000..b3bb207946 --- /dev/null +++ b/server/src/queries/map.repository.sql @@ -0,0 +1,31 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- MapRepository.getMapMarkers +select + "id", + "exif"."latitude" as "lat", + "exif"."longitude" as "lon", + "exif"."city", + "exif"."state", + "exif"."country" +from + "assets" + inner join "exif" on "assets"."id" = "exif"."assetId" + and "exif"."latitude" is not null + and "exif"."longitude" is not null +where + "isVisible" = $1 + and "deletedAt" is null + and ( + "ownerId" in ($2) + or exists ( + select + from + "albums_assets_assets" + where + "assets"."id" = "albums_assets_assets"."assetsId" + and "albums_assets_assets"."albumsId" in ($3) + ) + ) +order by + "fileCreatedAt" desc diff --git a/server/src/queries/memory.repository.sql b/server/src/queries/memory.repository.sql index 3144f314dd..d44d017045 100644 --- a/server/src/queries/memory.repository.sql +++ b/server/src/queries/memory.repository.sql @@ -1,12 +1,72 @@ -- NOTE: This file is auto generated by ./sql-generator +-- MemoryRepository.cleanup +delete from "memories" +where + "createdAt" < $1 + and "isSaved" = $2 + -- MemoryRepository.search select - * + "memories".*, + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "assets".* + from + "assets" + inner join "memories_assets_assets" on "assets"."id" = "memories_assets_assets"."assetsId" + where + "memories_assets_assets"."memoriesId" = "memories"."id" + and "assets"."deletedAt" is null + order by + "assets"."fileCreatedAt" asc + ) as agg + ) as "assets" from "memories" where - "ownerId" = $1 + "deletedAt" is null + and "ownerId" = $1 +order by + "memoryAt" desc + +-- MemoryRepository.search (date filter) +select + "memories".*, + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "assets".* + from + "assets" + inner join "memories_assets_assets" on "assets"."id" = "memories_assets_assets"."assetsId" + where + "memories_assets_assets"."memoriesId" = "memories"."id" + and "assets"."deletedAt" is null + order by + "assets"."fileCreatedAt" asc + ) as agg + ) as "assets" +from + "memories" +where + ( + "showAt" is null + or "showAt" <= $1 + ) + and ( + "hideAt" is null + or "hideAt" >= $2 + ) + and "deletedAt" is null + and "ownerId" = $3 order by "memoryAt" desc @@ -26,6 +86,8 @@ select where "memories_assets_assets"."memoriesId" = "memories"."id" and "assets"."deletedAt" is null + order by + "assets"."fileCreatedAt" asc ) as agg ) as "assets" from @@ -56,6 +118,8 @@ select where "memories_assets_assets"."memoriesId" = "memories"."id" and "assets"."deletedAt" is null + order by + "assets"."fileCreatedAt" asc ) as agg ) as "assets" from diff --git a/server/src/queries/move.repository.sql b/server/src/queries/move.repository.sql index e51f2829df..a65c7a8b85 100644 --- a/server/src/queries/move.repository.sql +++ b/server/src/queries/move.repository.sql @@ -15,3 +15,22 @@ where "id" = $1 returning * + +-- MoveRepository.cleanMoveHistory +delete from "move_history" +where + "move_history"."entityId" not in ( + select + "id" + from + "assets" + where + "assets"."id" = "move_history"."entityId" + ) + and "move_history"."pathType" = 'original' + +-- MoveRepository.cleanMoveHistorySingle +delete from "move_history" +where + "move_history"."pathType" = 'original' + and "entityId" = $1 diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 2c06d7c3f2..e6868ae302 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -42,6 +42,8 @@ select from "person" left join "asset_faces" on "asset_faces"."personId" = "person"."id" +where + "asset_faces"."deletedAt" is null group by "person"."id" having @@ -67,6 +69,7 @@ from "asset_faces" where "asset_faces"."assetId" = $1 + and "asset_faces"."deletedAt" is null order by "asset_faces"."boundingBoxX1" asc @@ -90,6 +93,7 @@ from "asset_faces" where "asset_faces"."id" = $1 + and "asset_faces"."deletedAt" is null -- PersonRepository.getFaceByIdWithAssets select @@ -124,6 +128,7 @@ from "asset_faces" where "asset_faces"."id" = $1 + and "asset_faces"."deletedAt" is null -- PersonRepository.reassignFace update "asset_faces" @@ -169,7 +174,8 @@ from and "asset_faces"."personId" = $1 and "assets"."isArchived" = $2 and "assets"."deletedAt" is null - and "assets"."livePhotoVideoId" is null +where + "asset_faces"."deletedAt" is null -- PersonRepository.getNumberOfPeople select @@ -186,6 +192,7 @@ from and "assets"."isArchived" = $2 where "person"."ownerId" = $3 + and "asset_faces"."deletedAt" is null -- PersonRepository.refreshFaces with @@ -236,6 +243,7 @@ from where "asset_faces"."assetId" in ($1) and "asset_faces"."personId" in ($2) + and "asset_faces"."deletedAt" is null -- PersonRepository.getRandomFace select @@ -244,9 +252,22 @@ from "asset_faces" where "asset_faces"."personId" = $1 + and "asset_faces"."deletedAt" is null -- PersonRepository.getLatestFaceDate select max("asset_job_status"."facesRecognizedAt")::text as "latestDate" from "asset_job_status" + +-- PersonRepository.deleteAssetFace +delete from "asset_faces" +where + "asset_faces"."id" = $1 + +-- PersonRepository.softDeleteAssetFaces +update "asset_faces" +set + "deletedAt" = $1 +where + "asset_faces"."id" = $2 diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 2d5da4d381..06590dc817 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -13,6 +13,9 @@ where and "assets"."isFavorite" = $4 and "assets"."isArchived" = $5 and "assets"."deletedAt" is null + and "assets"."fileCreatedAt" is not null + and "assets"."fileModifiedAt" is not null + and "assets"."localDateTime" is not null order by "assets"."fileCreatedAt" desc limit @@ -34,9 +37,12 @@ offset and "assets"."isFavorite" = $4 and "assets"."isArchived" = $5 and "assets"."deletedAt" is null + and "assets"."fileCreatedAt" is not null + and "assets"."fileModifiedAt" is not null + and "assets"."localDateTime" is not null and "assets"."id" < $6 order by - "assets"."id" + random() limit $7 ) @@ -54,9 +60,12 @@ union all and "assets"."isFavorite" = $11 and "assets"."isArchived" = $12 and "assets"."deletedAt" is null + and "assets"."fileCreatedAt" is not null + and "assets"."fileModifiedAt" is not null + and "assets"."localDateTime" is not null and "assets"."id" > $13 order by - "assets"."id" + random() limit $14 ) @@ -77,6 +86,9 @@ where and "assets"."isFavorite" = $4 and "assets"."isArchived" = $5 and "assets"."deletedAt" is null + and "assets"."fileCreatedAt" is not null + and "assets"."fileModifiedAt" is not null + and "assets"."localDateTime" is not null order by smart_search.embedding <=> $6 limit @@ -100,6 +112,7 @@ with and "assets"."isVisible" = $3 and "assets"."type" = $4 and "assets"."id" != $5::uuid + and "assets"."stackId" is null order by smart_search.embedding <=> $6 limit diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql index b928195e72..3d115615fd 100644 --- a/server/src/queries/session.repository.sql +++ b/server/src/queries/session.repository.sql @@ -10,41 +10,29 @@ where -- SessionRepository.getByToken select - "sessions".*, - to_json("user") as "user" -from - "sessions" - inner join lateral ( + "sessions"."id", + "sessions"."updatedAt", + ( select - "id", - "email", - "createdAt", - "profileImagePath", - "isAdmin", - "shouldChangePassword", - "deletedAt", - "oauthId", - "updatedAt", - "storageLabel", - "name", - "quotaSizeInBytes", - "quotaUsageInBytes", - "status", - "profileChangedAt", + to_json(obj) + from ( select - array_agg("user_metadata") as "metadata" + "users"."id", + "users"."name", + "users"."email", + "users"."isAdmin", + "users"."quotaUsageInBytes", + "users"."quotaSizeInBytes" from - "user_metadata" + "users" where - "users"."id" = "user_metadata"."userId" - ) as "metadata" - from - "users" - where - "users"."id" = "sessions"."userId" - and "users"."deletedAt" is null - ) as "user" on true + "users"."id" = "sessions"."userId" + and "users"."deletedAt" is null + ) as obj + ) as "user" +from + "sessions" where "sessions"."token" = $1 diff --git a/server/src/queries/shared.link.repository.sql b/server/src/queries/shared.link.repository.sql index 1861ed86e4..641996e2f4 100644 --- a/server/src/queries/shared.link.repository.sql +++ b/server/src/queries/shared.link.repository.sql @@ -100,13 +100,14 @@ order by -- SharedLinkRepository.getAll select distinct on ("shared_links"."createdAt") "shared_links".*, + to_json("assets") as "assets", to_json("album") as "album" from "shared_links" left join "shared_link__asset" on "shared_link__asset"."sharedLinksId" = "shared_links"."id" left join lateral ( select - "assets".* + json_agg("assets") as "assets" from "assets" where @@ -152,12 +153,19 @@ where "shared_links"."type" = $2 or "album"."id" is not null ) + and "shared_links"."albumId" = $3 order by "shared_links"."createdAt" desc -- SharedLinkRepository.getByKey select - "shared_links".*, + "shared_links"."id", + "shared_links"."userId", + "shared_links"."expiresAt", + "shared_links"."showExif", + "shared_links"."allowUpload", + "shared_links"."allowDownload", + "shared_links"."password", ( select to_json(obj) @@ -165,20 +173,11 @@ select ( select "users"."id", - "users"."email", - "users"."createdAt", - "users"."profileImagePath", - "users"."isAdmin", - "users"."shouldChangePassword", - "users"."deletedAt", - "users"."oauthId", - "users"."updatedAt", - "users"."storageLabel", "users"."name", - "users"."quotaSizeInBytes", + "users"."email", + "users"."isAdmin", "users"."quotaUsageInBytes", - "users"."status", - "users"."profileChangedAt" + "users"."quotaSizeInBytes" from "users" where diff --git a/server/src/queries/tag.repository.sql b/server/src/queries/tag.repository.sql index 580344c597..6c97d7843f 100644 --- a/server/src/queries/tag.repository.sql +++ b/server/src/queries/tag.repository.sql @@ -1,10 +1,118 @@ -- NOTE: This file is auto generated by ./sql-generator --- TagRepository.getAssetIds -SELECT - "tag_asset"."assetsId" AS "assetId" -FROM - "tag_asset" "tag_asset" -WHERE - "tag_asset"."tagsId" = $1 - AND "tag_asset"."assetsId" IN ($2) +-- TagRepository.get +select + "id", + "value", + "createdAt", + "updatedAt", + "color", + "parentId" +from + "tags" +where + "id" = $1 + +-- TagRepository.getByValue +select + "id", + "value", + "createdAt", + "updatedAt", + "color", + "parentId" +from + "tags" +where + "userId" = $1 + and "value" = $2 + +-- TagRepository.upsertValue +begin +insert into + "tags" ("userId", "value", "parentId") +values + ($1, $2, $3) +on conflict ("userId", "value") do update +set + "parentId" = $4 +returning + * +rollback + +-- TagRepository.getAll +select + "id", + "value", + "createdAt", + "updatedAt", + "color", + "parentId" +from + "tags" +where + "userId" = $1 +order by + "value" asc + +-- TagRepository.create +insert into + "tags" ("userId", "color", "value") +values + ($1, $2, $3) +returning + * + +-- TagRepository.update +update "tags" +set + "color" = $1 +where + "id" = $2 +returning + * + +-- TagRepository.delete +delete from "tags" +where + "id" = $1 + +-- TagRepository.addAssetIds +insert into + "tag_asset" ("tagsId", "assetsId") +values + ($1, $2) + +-- TagRepository.removeAssetIds +delete from "tag_asset" +where + "tagsId" = $1 + and "assetsId" in ($2) + +-- TagRepository.replaceAssetTags +begin +delete from "tag_asset" +where + "assetsId" = $1 +insert into + "tag_asset" ("tagsId", "assetsId") +values + ($1, $2) +on conflict do nothing +returning + * +rollback + +-- TagRepository.deleteEmptyTags +begin +select + "tags"."id", + count("assets"."id") as "count" +from + "assets" + inner join "tag_asset" on "tag_asset"."assetsId" = "assets"."id" + inner join "tags_closure" on "tags_closure"."id_descendant" = "tag_asset"."tagsId" + inner join "tags" on "tags"."id" = "tags_closure"."id_descendant" +group by + "tags"."id" +commit diff --git a/server/src/queries/view.repository.sql b/server/src/queries/view.repository.sql index b368684cae..1510521526 100644 --- a/server/src/queries/view.repository.sql +++ b/server/src/queries/view.repository.sql @@ -10,6 +10,9 @@ where and "isVisible" = $3 and "isArchived" = $4 and "deletedAt" is null + and "fileCreatedAt" is not null + and "fileModifiedAt" is not null + and "localDateTime" is not null -- ViewRepository.getAssetsByOriginalPath select @@ -23,6 +26,9 @@ where and "isVisible" = $2 and "isArchived" = $3 and "deletedAt" is null + and "fileCreatedAt" is not null + and "fileModifiedAt" is not null + and "localDateTime" is not null and "originalPath" like $4 and "originalPath" not like $5 order by diff --git a/server/src/repositories/activity.repository.ts b/server/src/repositories/activity.repository.ts index 99d3192341..d998fea23c 100644 --- a/server/src/repositories/activity.repository.ts +++ b/server/src/repositories/activity.repository.ts @@ -65,6 +65,9 @@ export class ActivityRepository { .where('activity.albumId', '=', albumId) .where('activity.isLiked', '=', false) .where('assets.deletedAt', 'is', null) + .where('assets.fileCreatedAt', 'is not', null) + .where('assets.fileModifiedAt', 'is not', null) + .where('assets.localDateTime', 'is not', null) .executeTakeFirstOrThrow(); return count as number; diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index e32a53e82d..1fe2938fcc 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -6,7 +6,17 @@ import { Albums, DB } from 'src/db'; import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { AlbumUserCreateDto } from 'src/dtos/album.dto'; import { AlbumEntity } from 'src/entities/album.entity'; -import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/interfaces/album.interface'; + +export interface AlbumAssetCount { + albumId: string; + assetCount: number; + startDate: Date | null; + endDate: Date | null; +} + +export interface AlbumInfoOptions { + withAssets: boolean; +} const userColumns = [ 'id', @@ -59,11 +69,10 @@ const withAssets = (eb: ExpressionBuilder) => { .selectFrom('assets') .selectAll('assets') .innerJoin('exif', 'assets.id', 'exif.assetId') - .select((eb) => eb.fn.toJson('exif').as('exifInfo')) + .select((eb) => eb.table('exif').as('exifInfo')) .innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id') .whereRef('albums_assets_assets.albumsId', '=', 'albums.id') .where('assets.deletedAt', 'is', null) - .where('assets.isArchived', '=', false) .orderBy('assets.fileCreatedAt', 'desc') .as('asset'), ) @@ -72,7 +81,7 @@ const withAssets = (eb: ExpressionBuilder) => { }; @Injectable() -export class AlbumRepository implements IAlbumRepository { +export class AlbumRepository { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID, { withAssets: true }] }) @@ -94,14 +103,19 @@ export class AlbumRepository implements IAlbumRepository { return this.db .selectFrom('albums') .selectAll('albums') - .leftJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id') - .leftJoin('albums_shared_users_users as album_users', 'album_users.albumsId', 'albums.id') + .innerJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id') .where((eb) => eb.or([ - eb.and([eb('albums.ownerId', '=', ownerId), eb('album_assets.assetsId', '=', assetId)]), - eb.and([eb('album_users.usersId', '=', ownerId), eb('album_assets.assetsId', '=', assetId)]), + eb('albums.ownerId', '=', ownerId), + eb.exists( + eb + .selectFrom('albums_shared_users_users as album_users') + .whereRef('album_users.albumsId', '=', 'albums.id') + .where('album_users.usersId', '=', ownerId), + ), ]), ) + .where('album_assets.assetsId', '=', assetId) .where('albums.deletedAt', 'is', null) .orderBy('albums.createdAt', 'desc') .select(withOwner) @@ -118,24 +132,18 @@ export class AlbumRepository implements IAlbumRepository { return []; } - const metadatas = await this.db + return this.db .selectFrom('albums') - .leftJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id') - .leftJoin('assets', 'assets.id', 'album_assets.assetsId') - .select('albums.id') - .select((eb) => eb.fn.min('assets.fileCreatedAt').as('startDate')) - .select((eb) => eb.fn.max('assets.fileCreatedAt').as('endDate')) - .select((eb) => eb.fn.count('assets.id').as('assetCount')) + .innerJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id') + .innerJoin('assets', 'assets.id', 'album_assets.assetsId') + .select('albums.id as albumId') + .select((eb) => eb.fn.min('assets.localDateTime').as('startDate')) + .select((eb) => eb.fn.max('assets.localDateTime').as('endDate')) + .select((eb) => sql`${eb.fn.count('assets.id')}::int`.as('assetCount')) .where('albums.id', 'in', ids) + .where('assets.deletedAt', 'is', null) .groupBy('albums.id') .execute(); - - return metadatas.map((metadatas) => ({ - albumId: metadatas.id, - assetCount: Number(metadatas.assetCount), - startDate: metadatas.startDate ? new Date(metadatas.startDate) : undefined, - endDate: metadatas.endDate ? new Date(metadatas.endDate) : undefined, - })); } @GenerateSql({ params: [DummyValue.UUID] }) @@ -160,14 +168,20 @@ export class AlbumRepository implements IAlbumRepository { return this.db .selectFrom('albums') .selectAll('albums') - .distinctOn('albums.createdAt') - .leftJoin('albums_shared_users_users as shared_albums', 'shared_albums.albumsId', 'albums.id') - .leftJoin('shared_links', 'shared_links.albumId', 'albums.id') .where((eb) => eb.or([ - eb('shared_albums.usersId', '=', ownerId), - eb('shared_links.userId', '=', ownerId), - eb.and([eb('albums.ownerId', '=', ownerId), eb('shared_albums.usersId', 'is not', null)]), + eb.exists( + eb + .selectFrom('albums_shared_users_users as album_users') + .whereRef('album_users.albumsId', '=', 'albums.id') + .where((eb) => eb.or([eb('albums.ownerId', '=', ownerId), eb('album_users.usersId', '=', ownerId)])), + ), + eb.exists( + eb + .selectFrom('shared_links') + .whereRef('shared_links.albumId', '=', 'albums.id') + .where('shared_links.userId', '=', ownerId), + ), ]), ) .where('albums.deletedAt', 'is', null) @@ -186,16 +200,21 @@ export class AlbumRepository implements IAlbumRepository { return this.db .selectFrom('albums') .selectAll('albums') - .distinctOn('albums.createdAt') - .leftJoin('albums_shared_users_users as shared_albums', 'shared_albums.albumsId', 'albums.id') - .leftJoin('shared_links', 'shared_links.albumId', 'albums.id') .where('albums.ownerId', '=', ownerId) - .where('shared_albums.usersId', 'is', null) - .where('shared_links.userId', 'is', null) .where('albums.deletedAt', 'is', null) - .select(withAlbumUsers) + .where((eb) => + eb.not( + eb.exists( + eb + .selectFrom('albums_shared_users_users as album_users') + .whereRef('album_users.albumsId', '=', 'albums.id'), + ), + ), + ) + .where((eb) => + eb.not(eb.exists(eb.selectFrom('shared_links').whereRef('shared_links.albumId', '=', 'albums.id'))), + ) .select(withOwner) - .select(withSharedLink) .orderBy('albums.createdAt', 'desc') .execute() as unknown as Promise; } @@ -282,7 +301,6 @@ export class AlbumRepository implements IAlbumRepository { .selectAll() .where('id', '=', newAlbum.id) .select(withOwner) - .select(withSharedLink) .select(withAssets) .select(withAlbumUsers) .executeTakeFirst() as unknown as Promise; @@ -292,7 +310,7 @@ export class AlbumRepository implements IAlbumRepository { update(id: string, album: Updateable): Promise { return this.db .updateTable('albums') - .set({ ...album, updatedAt: new Date() }) + .set(album) .where('id', '=', id) .returningAll('albums') .returning(withOwner) @@ -335,7 +353,6 @@ export class AlbumRepository implements IAlbumRepository { .select('album_assets.assetsId') .orderBy('assets.fileCreatedAt', 'desc') .limit(1), - updatedAt: new Date(), })) .where((eb) => eb.or([ diff --git a/server/src/repositories/api-key.repository.ts b/server/src/repositories/api-key.repository.ts index 5422ad569e..0085db2337 100644 --- a/server/src/repositories/api-key.repository.ts +++ b/server/src/repositories/api-key.repository.ts @@ -1,18 +1,18 @@ import { Injectable } from '@nestjs/common'; import { Insertable, Kysely, Updateable } from 'kysely'; +import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; +import { columns } from 'src/database'; import { ApiKeys, DB } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { asUuid } from 'src/utils/database'; -const columns = ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'] as const; - @Injectable() export class ApiKeyRepository { constructor(@InjectKysely() private db: Kysely) {} create(dto: Insertable) { - return this.db.insertInto('api_keys').values(dto).returningAll().executeTakeFirstOrThrow(); + return this.db.insertInto('api_keys').values(dto).returning(columns.apiKey).executeTakeFirstOrThrow(); } async update(userId: string, id: string, dto: Updateable) { @@ -21,7 +21,7 @@ export class ApiKeyRepository { .set(dto) .where('api_keys.userId', '=', userId) .where('id', '=', asUuid(id)) - .returningAll() + .returning(columns.apiKey) .executeTakeFirstOrThrow(); } @@ -33,29 +33,15 @@ export class ApiKeyRepository { getKey(hashedToken: string) { return this.db .selectFrom('api_keys') - .innerJoinLateral( - (eb) => + .select((eb) => [ + ...columns.authApiKey, + jsonObjectFrom( eb .selectFrom('users') - .selectAll('users') - .select((eb) => - eb - .selectFrom('user_metadata') - .whereRef('users.id', '=', 'user_metadata.userId') - .select((eb) => eb.fn('array_agg', [eb.table('user_metadata')]).as('metadata')) - .as('metadata'), - ) + .select(columns.authUser) .whereRef('users.id', '=', 'api_keys.userId') - .where('users.deletedAt', 'is', null) - .as('user'), - (join) => join.onTrue(), - ) - .select((eb) => [ - 'api_keys.id', - 'api_keys.key', - 'api_keys.userId', - 'api_keys.permissions', - eb.fn.toJson('user').as('user'), + .where('users.deletedAt', 'is', null), + ).as('user'), ]) .where('api_keys.key', '=', hashedToken) .executeTakeFirst(); @@ -65,7 +51,7 @@ export class ApiKeyRepository { getById(userId: string, id: string) { return this.db .selectFrom('api_keys') - .select(columns) + .select(columns.apiKey) .where('id', '=', asUuid(id)) .where('userId', '=', userId) .executeTakeFirst(); @@ -75,7 +61,7 @@ export class ApiKeyRepository { getByUserId(userId: string) { return this.db .selectFrom('api_keys') - .select(columns) + .select(columns.apiKey) .where('userId', '=', userId) .orderBy('createdAt', 'desc') .execute(); diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 9c506197d6..1e6429b32d 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -1,14 +1,13 @@ import { Injectable } from '@nestjs/common'; -import { Insertable, Kysely, Updateable, sql } from 'kysely'; +import { Insertable, Kysely, UpdateResult, Updateable, sql } from 'kysely'; import { isEmpty, isUndefined, omitBy } from 'lodash'; import { InjectKysely } from 'nestjs-kysely'; -import { ASSET_FILE_CONFLICT_KEYS, EXIF_CONFLICT_KEYS, JOB_STATUS_CONFLICT_KEYS } from 'src/constants'; import { AssetFiles, AssetJobStatus, Assets, DB, Exif } from 'src/db'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { AssetEntity, + AssetEntityPlaceholder, hasPeople, - hasPeopleCte, searchAssetBuilder, truncatedDate, withAlbums, @@ -22,34 +21,139 @@ import { withTagId, withTags, } from 'src/entities/asset.entity'; -import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; -import { - AssetDeltaSyncOptions, - AssetExploreFieldOptions, - AssetFullSyncOptions, - AssetGetByChecksumOptions, - AssetStats, - AssetStatsOptions, - AssetUpdateDuplicateOptions, - DayOfYearAssets, - DuplicateGroup, - GetByIdsRelations, - IAssetRepository, - LivePhotoSearchOptions, - MonthDay, - TimeBucketItem, - TimeBucketOptions, - TimeBucketSize, - WithProperty, - WithoutProperty, -} from 'src/interfaces/asset.interface'; -import { AssetSearchOptions, SearchExploreItem, SearchExploreItemSet } from 'src/interfaces/search.interface'; -import { MapMarker, MapMarkerSearchOptions } from 'src/repositories/map.repository'; -import { anyUuid, asUuid, mapUpsertColumns } from 'src/utils/database'; +import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum'; +import { AssetSearchOptions, SearchExploreItem, SearchExploreItemSet } from 'src/repositories/search.repository'; +import { StorageAsset } from 'src/types'; +import { anyUuid, asUuid, removeUndefinedKeys, unnest } from 'src/utils/database'; +import { globToSqlPattern } from 'src/utils/misc'; import { Paginated, PaginationOptions, paginationHelper } from 'src/utils/pagination'; +export type AssetStats = Record; + +export interface AssetStatsOptions { + isFavorite?: boolean; + isArchived?: boolean; + isTrashed?: boolean; +} + +export interface LivePhotoSearchOptions { + ownerId: string; + libraryId?: string | null; + livePhotoCID: string; + otherAssetId: string; + type: AssetType; +} + +export enum WithoutProperty { + THUMBNAIL = 'thumbnail', + ENCODED_VIDEO = 'encoded-video', + EXIF = 'exif', + SMART_SEARCH = 'smart-search', + DUPLICATE = 'duplicate', + FACES = 'faces', + SIDECAR = 'sidecar', +} + +export enum WithProperty { + SIDECAR = 'sidecar', +} + +export enum TimeBucketSize { + DAY = 'DAY', + MONTH = 'MONTH', +} + +export interface AssetBuilderOptions { + isArchived?: boolean; + isFavorite?: boolean; + isTrashed?: boolean; + isDuplicate?: boolean; + albumId?: string; + tagId?: string; + personId?: string; + userIds?: string[]; + withStacked?: boolean; + exifInfo?: boolean; + status?: AssetStatus; + assetType?: AssetType; +} + +export interface TimeBucketOptions extends AssetBuilderOptions { + size: TimeBucketSize; + order?: AssetOrder; +} + +export interface TimeBucketItem { + timeBucket: string; + count: number; +} + +export interface MonthDay { + day: number; + month: number; +} + +export interface AssetExploreFieldOptions { + maxFields: number; + minAssetsPerField: number; +} + +export interface AssetFullSyncOptions { + ownerId: string; + lastId?: string; + updatedUntil: Date; + limit: number; +} + +export interface AssetDeltaSyncOptions { + userIds: string[]; + updatedAfter: Date; + limit: number; +} + +export interface AssetUpdateDuplicateOptions { + targetDuplicateId: string | null; + assetIds: string[]; + duplicateIds: string[]; +} + +export interface UpsertFileOptions { + assetId: string; + type: AssetFileType; + path: string; +} + +export interface AssetGetByChecksumOptions { + ownerId: string; + checksum: Buffer; + libraryId?: string; +} + +export type AssetPathEntity = Pick; + +export interface GetByIdsRelations { + exifInfo?: boolean; + faces?: { person?: boolean; withDeleted?: boolean }; + files?: boolean; + library?: boolean; + owner?: boolean; + smartSearch?: boolean; + stack?: { assets?: boolean }; + tags?: boolean; +} + +export interface DuplicateGroup { + duplicateId: string; + assets: AssetEntity[]; +} + +export interface DayOfYearAssets { + yearsAgo: number; + assets: AssetEntity[]; +} + @Injectable() -export class AssetRepository implements IAssetRepository { +export class AssetRepository { constructor(@InjectKysely() private db: Kysely) {} async upsertExif(exif: Insertable): Promise { @@ -58,7 +162,41 @@ export class AssetRepository implements IAssetRepository { .insertInto('exif') .values(value) .onConflict((oc) => - oc.columns(EXIF_CONFLICT_KEYS).doUpdateSet(() => mapUpsertColumns('exif', value, EXIF_CONFLICT_KEYS)), + oc.column('assetId').doUpdateSet((eb) => + removeUndefinedKeys( + { + description: eb.ref('excluded.description'), + exifImageWidth: eb.ref('excluded.exifImageWidth'), + exifImageHeight: eb.ref('excluded.exifImageHeight'), + fileSizeInByte: eb.ref('excluded.fileSizeInByte'), + orientation: eb.ref('excluded.orientation'), + dateTimeOriginal: eb.ref('excluded.dateTimeOriginal'), + modifyDate: eb.ref('excluded.modifyDate'), + timeZone: eb.ref('excluded.timeZone'), + latitude: eb.ref('excluded.latitude'), + longitude: eb.ref('excluded.longitude'), + projectionType: eb.ref('excluded.projectionType'), + city: eb.ref('excluded.city'), + livePhotoCID: eb.ref('excluded.livePhotoCID'), + autoStackId: eb.ref('excluded.autoStackId'), + state: eb.ref('excluded.state'), + country: eb.ref('excluded.country'), + make: eb.ref('excluded.make'), + model: eb.ref('excluded.model'), + lensModel: eb.ref('excluded.lensModel'), + fNumber: eb.ref('excluded.fNumber'), + focalLength: eb.ref('excluded.focalLength'), + iso: eb.ref('excluded.iso'), + exposureTime: eb.ref('excluded.exposureTime'), + profileDescription: eb.ref('excluded.profileDescription'), + colorspace: eb.ref('excluded.colorspace'), + bitsPerSample: eb.ref('excluded.bitsPerSample'), + rating: eb.ref('excluded.rating'), + fps: eb.ref('excluded.fps'), + }, + value, + ), + ), ) .execute(); } @@ -73,19 +211,36 @@ export class AssetRepository implements IAssetRepository { .insertInto('asset_job_status') .values(values) .onConflict((oc) => - oc - .columns(JOB_STATUS_CONFLICT_KEYS) - .doUpdateSet(() => mapUpsertColumns('asset_job_status', values[0], JOB_STATUS_CONFLICT_KEYS)), + oc.column('assetId').doUpdateSet((eb) => + removeUndefinedKeys( + { + duplicatesDetectedAt: eb.ref('excluded.duplicatesDetectedAt'), + facesRecognizedAt: eb.ref('excluded.facesRecognizedAt'), + metadataExtractedAt: eb.ref('excluded.metadataExtractedAt'), + previewAt: eb.ref('excluded.previewAt'), + thumbnailAt: eb.ref('excluded.thumbnailAt'), + }, + values[0], + ), + ), ) .execute(); } - create(asset: Insertable): Promise { - return this.db.insertInto('assets').values(asset).returningAll().executeTakeFirst() as any as Promise; + create(asset: Insertable): Promise { + return this.db + .insertInto('assets') + .values(asset) + .returningAll() + .executeTakeFirst() as any as Promise; + } + + createAll(assets: Insertable[]): Promise { + return this.db.insertInto('assets').values(assets).returningAll().execute() as any as Promise; } @GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] }) - getByDayOfYear(ownerIds: string[], { day, month }: MonthDay): Promise { + getByDayOfYear(ownerIds: string[], { day, month }: MonthDay) { return this.db .with('res', (qb) => qb @@ -122,6 +277,7 @@ export class AssetRepository implements IAssetRepository { ), ) .where('assets.deletedAt', 'is', null) + .orderBy(sql`(assets."localDateTime" at time zone 'UTC')::date`, 'desc') .limit(20) .as('a'), (join) => join.onTrue(), @@ -131,16 +287,12 @@ export class AssetRepository implements IAssetRepository { .select((eb) => eb.fn.toJson(eb.table('exif')).as('exifInfo')), ) .selectFrom('res') - .select( - sql`((now() at time zone 'UTC')::date - ("localDateTime" at time zone 'UTC')::date) / 365`.as( - 'yearsAgo', - ), - ) + .select(sql`date_part('year', ("localDateTime" at time zone 'UTC')::date)::int`.as('year')) .select((eb) => eb.fn.jsonAgg(eb.table('res')).as('assets')) .groupBy(sql`("localDateTime" at time zone 'UTC')::date`) .orderBy(sql`("localDateTime" at time zone 'UTC')::date`, 'desc') .limit(10) - .execute() as any as Promise; + .execute(); } @GenerateSql({ params: [[DummyValue.UUID]] }) @@ -154,7 +306,11 @@ export class AssetRepository implements IAssetRepository { .selectAll('assets') .where('assets.id', '=', anyUuid(ids)) .$if(!!exifInfo, withExif) - .$if(!!faces, (qb) => qb.select(faces?.person ? withFacesAndPeople : withFaces)) + .$if(!!faces, (qb) => + qb.select((eb) => + faces?.person ? withFacesAndPeople(eb, faces.withDeleted) : withFaces(eb, faces?.withDeleted), + ), + ) .$if(!!files, (qb) => qb.select(withFiles)) .$if(!!library, (qb) => qb.select(withLibrary)) .$if(!!owner, (qb) => qb.select(withOwner)) @@ -291,6 +447,9 @@ export class AssetRepository implements IAssetRepository { .where('ownerId', '=', asUuid(ownerId)) .where('deviceId', '=', deviceId) .where('isVisible', '=', true) + .where('assets.fileCreatedAt', 'is not', null) + .where('assets.fileModifiedAt', 'is not', null) + .where('assets.localDateTime', 'is not', null) .where('deletedAt', 'is', null) .execute(); @@ -359,6 +518,10 @@ export class AssetRepository implements IAssetRepository { await this.db.updateTable('assets').set(options).where('id', '=', anyUuid(ids)).execute(); } + async updateByLibraryId(libraryId: string, options: Updateable): Promise { + await this.db.updateTable('assets').set(options).where('libraryId', '=', asUuid(libraryId)).execute(); + } + @GenerateSql({ params: [{ targetDuplicateId: DummyValue.UUID, duplicateIds: [DummyValue.UUID], assetIds: [DummyValue.UUID] }], }) @@ -388,7 +551,7 @@ export class AssetRepository implements IAssetRepository { return this.getById(asset.id, { exifInfo: true, faces: { person: true } }) as Promise; } - async remove(asset: AssetEntity): Promise { + async remove(asset: { id: string }): Promise { await this.db.deleteFrom('assets').where('id', '=', asUuid(asset.id)).execute(); } @@ -430,9 +593,9 @@ export class AssetRepository implements IAssetRepository { findLivePhotoMatch(options: LivePhotoSearchOptions): Promise { const { ownerId, otherAssetId, livePhotoCID, type } = options; - return this.db .selectFrom('assets') + .select('assets.id') .innerJoin('exif', 'assets.id', 'exif.assetId') .where('id', '!=', asUuid(otherAssetId)) .where('ownerId', '=', asUuid(ownerId)) @@ -442,6 +605,46 @@ export class AssetRepository implements IAssetRepository { .executeTakeFirst() as Promise; } + private storageTemplateAssetQuery() { + return this.db + .selectFrom('assets') + .innerJoin('exif', 'assets.id', 'exif.assetId') + .select([ + 'assets.id', + 'assets.ownerId', + 'assets.type', + 'assets.checksum', + 'assets.originalPath', + 'assets.isExternal', + 'assets.sidecarPath', + 'assets.originalFileName', + 'assets.livePhotoVideoId', + 'assets.fileCreatedAt', + 'exif.timeZone', + 'exif.fileSizeInByte', + ]) + .where('assets.deletedAt', 'is', null) + .where('assets.fileCreatedAt', 'is not', null); + } + + getStorageTemplateAsset(id: string): Promise { + return this.storageTemplateAssetQuery().where('assets.id', '=', id).executeTakeFirst() as Promise< + StorageAsset | undefined + >; + } + + streamStorageTemplateAssets() { + return this.storageTemplateAssetQuery().stream() as AsyncIterableIterator; + } + + streamDeletedAssets(trashedBefore: Date) { + return this.db + .selectFrom('assets') + .select(['id', 'isOffline']) + .where('assets.deletedAt', '<=', trashedBefore) + .stream(); + } + @GenerateSql( ...Object.values(WithProperty).map((property) => ({ name: property, @@ -458,7 +661,10 @@ export class AssetRepository implements IAssetRepository { .where('job_status.duplicatesDetectedAt', 'is', null) .where('job_status.previewAt', 'is not', null) .where((eb) => eb.exists(eb.selectFrom('smart_search').where('assetId', '=', eb.ref('assets.id')))) - .where('assets.isVisible', '=', true), + .where('assets.isVisible', '=', true) + .where('assets.fileCreatedAt', 'is not', null) + .where('assets.fileModifiedAt', 'is not', null) + .where('assets.localDateTime', 'is not', null), ) .$if(property === WithoutProperty.ENCODED_VIDEO, (qb) => qb @@ -467,8 +673,8 @@ export class AssetRepository implements IAssetRepository { ) .$if(property === WithoutProperty.EXIF, (qb) => qb - .innerJoin('asset_job_status as job_status', 'assets.id', 'job_status.assetId') - .where('job_status.metadataExtractedAt', 'is', null) + .leftJoin('asset_job_status as job_status', 'assets.id', 'job_status.assetId') + .where((eb) => eb.or([eb('job_status.metadataExtractedAt', 'is', null), eb('assetId', 'is', null)])) .where('assets.isVisible', '=', true), ) .$if(property === WithoutProperty.FACES, (qb) => @@ -495,7 +701,6 @@ export class AssetRepository implements IAssetRepository { .$if(property === WithoutProperty.THUMBNAIL, (qb) => qb .innerJoin('asset_job_status as job_status', 'assetId', 'assets.id') - .select(withFiles) .where('assets.isVisible', '=', true) .where((eb) => eb.or([ @@ -525,26 +730,6 @@ export class AssetRepository implements IAssetRepository { .executeTakeFirst() as Promise; } - getMapMarkers(ownerIds: string[], options: MapMarkerSearchOptions = {}): Promise { - const { isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore } = options; - - return this.db - .selectFrom('assets') - .leftJoin('exif', 'assets.id', 'exif.assetId') - .select(['id', 'latitude as lat', 'longitude as lon', 'city', 'state', 'country']) - .where('ownerId', '=', anyUuid(ownerIds)) - .where('latitude', 'is not', null) - .where('longitude', 'is not', null) - .where('isVisible', '=', true) - .where('deletedAt', 'is', null) - .$if(!!isArchived, (qb) => qb.where('isArchived', '=', isArchived!)) - .$if(!!isFavorite, (qb) => qb.where('isFavorite', '=', isFavorite!)) - .$if(!!fileCreatedAfter, (qb) => qb.where('fileCreatedAt', '>=', fileCreatedAfter!)) - .$if(!!fileCreatedBefore, (qb) => qb.where('fileCreatedAt', '<=', fileCreatedBefore!)) - .orderBy('fileCreatedAt', 'desc') - .execute() as Promise; - } - getStatistics(ownerId: string, { isArchived, isFavorite, isTrashed }: AssetStatsOptions): Promise { return this.db .selectFrom('assets') @@ -553,6 +738,9 @@ export class AssetRepository implements IAssetRepository { .select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.VIDEO).as(AssetType.VIDEO)) .select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.OTHER).as(AssetType.OTHER)) .where('ownerId', '=', asUuid(ownerId)) + .where('assets.fileCreatedAt', 'is not', null) + .where('assets.fileModifiedAt', 'is not', null) + .where('assets.localDateTime', 'is not', null) .where('isVisible', '=', true) .$if(isArchived !== undefined, (qb) => qb.where('isArchived', '=', isArchived!)) .$if(isFavorite !== undefined, (qb) => qb.where('isFavorite', '=', isFavorite!)) @@ -577,7 +765,7 @@ export class AssetRepository implements IAssetRepository { @GenerateSql({ params: [{ size: TimeBucketSize.MONTH }] }) async getTimeBuckets(options: TimeBucketOptions): Promise { return ( - ((options.personId ? hasPeopleCte(this.db, [options.personId]) : this.db) as Kysely) + this.db .with('assets', (qb) => qb .selectFrom('assets') @@ -585,16 +773,15 @@ export class AssetRepository implements IAssetRepository { .$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED)) .where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null) .where('assets.isVisible', '=', true) + .where('assets.fileCreatedAt', 'is not', null) + .where('assets.fileModifiedAt', 'is not', null) + .where('assets.localDateTime', 'is not', null) .$if(!!options.albumId, (qb) => qb .innerJoin('albums_assets_assets', 'assets.id', 'albums_assets_assets.assetsId') .where('albums_assets_assets.albumsId', '=', asUuid(options.albumId!)), ) - .$if(!!options.personId, (qb) => - qb.innerJoin(sql.table('has_people').as('has_people'), (join) => - join.onRef(sql`has_people."assetId"`, '=', 'assets.id'), - ), - ) + .$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!])) .$if(!!options.withStacked, (qb) => qb .leftJoin('asset_stack', (join) => @@ -629,10 +816,12 @@ export class AssetRepository implements IAssetRepository { @GenerateSql({ params: [DummyValue.TIME_BUCKET, { size: TimeBucketSize.MONTH, withStacked: true }] }) async getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise { - return hasPeople(this.db, options.personId ? [options.personId] : undefined) + return this.db + .selectFrom('assets') .selectAll('assets') .$call(withExif) .$if(!!options.albumId, (qb) => withAlbums(qb, { albumId: options.albumId })) + .$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!])) .$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!))) .$if(options.isArchived !== undefined, (qb) => qb.where('assets.isArchived', '=', options.isArchived!)) .$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!)) @@ -677,13 +866,24 @@ export class AssetRepository implements IAssetRepository { .with('duplicates', (qb) => qb .selectFrom('assets') - .select('duplicateId') - .select((eb) => eb.fn('jsonb_agg', [eb.table('assets')]).as('assets')) - .where('ownerId', '=', asUuid(userId)) - .where('duplicateId', 'is not', null) - .where('deletedAt', 'is', null) - .where('isVisible', '=', true) - .groupBy('duplicateId'), + .leftJoinLateral( + (qb) => + qb + .selectFrom('exif') + .selectAll('assets') + .select((eb) => eb.table('exif').as('exifInfo')) + .whereRef('exif.assetId', '=', 'assets.id') + .as('asset'), + (join) => join.onTrue(), + ) + .select('assets.duplicateId') + .select((eb) => eb.fn('jsonb_agg', [eb.table('asset')]).as('assets')) + .where('assets.ownerId', '=', asUuid(userId)) + .where('assets.duplicateId', 'is not', null) + .where('assets.deletedAt', 'is', null) + .where('assets.isVisible', '=', true) + .where('assets.stackId', 'is', null) + .groupBy('assets.duplicateId'), ) .with('unique', (qb) => qb @@ -768,8 +968,8 @@ export class AssetRepository implements IAssetRepository { ) .select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')) .where('assets.ownerId', '=', asUuid(ownerId)) - .where('isVisible', '=', true) - .where('updatedAt', '<=', updatedUntil) + .where('assets.isVisible', '=', true) + .where('assets.updatedAt', '<=', updatedUntil) .$if(!!lastId, (qb) => qb.where('assets.id', '>', lastId!)) .orderBy('assets.id') .limit(limit) @@ -796,8 +996,8 @@ export class AssetRepository implements IAssetRepository { ) .select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')) .where('assets.ownerId', '=', anyUuid(options.userIds)) - .where('isVisible', '=', true) - .where('updatedAt', '>', options.updatedAfter) + .where('assets.isVisible', '=', true) + .where('assets.updatedAt', '>', options.updatedAfter) .limit(options.limit) .execute() as any as Promise; } @@ -808,9 +1008,9 @@ export class AssetRepository implements IAssetRepository { .insertInto('asset_files') .values(value) .onConflict((oc) => - oc - .columns(ASSET_FILE_CONFLICT_KEYS) - .doUpdateSet(() => mapUpsertColumns('asset_files', value, ASSET_FILE_CONFLICT_KEYS)), + oc.columns(['assetId', 'type']).doUpdateSet((eb) => ({ + path: eb.ref('excluded.path'), + })), ) .execute(); } @@ -825,10 +1025,70 @@ export class AssetRepository implements IAssetRepository { .insertInto('asset_files') .values(values) .onConflict((oc) => - oc - .columns(ASSET_FILE_CONFLICT_KEYS) - .doUpdateSet(() => mapUpsertColumns('asset_files', values[0], ASSET_FILE_CONFLICT_KEYS)), + oc.columns(['assetId', 'type']).doUpdateSet((eb) => ({ + path: eb.ref('excluded.path'), + })), ) .execute(); } + + @GenerateSql({ + params: [{ libraryId: DummyValue.UUID, importPaths: [DummyValue.STRING], exclusionPatterns: [DummyValue.STRING] }], + }) + async detectOfflineExternalAssets( + libraryId: string, + importPaths: string[], + exclusionPatterns: string[], + ): Promise { + const paths = importPaths.map((importPath) => `${importPath}%`); + const exclusions = exclusionPatterns.map((pattern) => globToSqlPattern(pattern)); + + return this.db + .updateTable('assets') + .set({ + isOffline: true, + deletedAt: new Date(), + }) + .where('isOffline', '=', false) + .where('isExternal', '=', true) + .where('libraryId', '=', asUuid(libraryId)) + .where((eb) => + eb.or([eb('originalPath', 'not like', paths.join('|')), eb('originalPath', 'like', exclusions.join('|'))]), + ) + .executeTakeFirstOrThrow(); + } + + @GenerateSql({ + params: [{ libraryId: DummyValue.UUID, paths: [DummyValue.STRING] }], + }) + async filterNewExternalAssetPaths(libraryId: string, paths: string[]): Promise { + const result = await this.db + .selectFrom(unnest(paths).as('path')) + .select('path') + .where((eb) => + eb.not( + eb.exists( + this.db + .selectFrom('assets') + .select('originalPath') + .whereRef('assets.originalPath', '=', eb.ref('path')) + .where('libraryId', '=', asUuid(libraryId)) + .where('isExternal', '=', true), + ), + ), + ) + .execute(); + + return result.map((row) => row.path as string); + } + + async getLibraryAssetCount(libraryId: string): Promise { + const { count } = await this.db + .selectFrom('assets') + .select((eb) => eb.fn.countAll().as('count')) + .where('libraryId', '=', asUuid(libraryId)) + .executeTakeFirstOrThrow(); + + return Number(count); + } } diff --git a/server/src/repositories/config.repository.spec.ts b/server/src/repositories/config.repository.spec.ts index 19068ddc5d..888d5c33ec 100644 --- a/server/src/repositories/config.repository.spec.ts +++ b/server/src/repositories/config.repository.spec.ts @@ -1,4 +1,3 @@ -import { PostgresJSDialect } from 'kysely-postgres-js'; import { ImmichTelemetry } from 'src/enum'; import { clearEnvCache, ConfigRepository } from 'src/repositories/config.repository'; @@ -81,10 +80,13 @@ describe('getEnv', () => { const { database } = getEnv(); expect(database).toEqual({ config: { - kysely: { - dialect: expect.any(PostgresJSDialect), - log: expect.any(Function), - }, + kysely: expect.objectContaining({ + host: 'database', + port: 5432, + database: 'immich', + username: 'postgres', + password: 'postgres', + }), typeorm: expect.objectContaining({ type: 'postgres', host: 'database', @@ -104,6 +106,94 @@ describe('getEnv', () => { const { database } = getEnv(); expect(database).toMatchObject({ skipMigrations: true }); }); + + it('should use DB_URL', () => { + process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich'; + const { database } = getEnv(); + expect(database.config.kysely).toMatchObject({ + host: 'database1', + password: 'postgres2', + user: 'postgres1', + port: 54_320, + database: 'immich', + }); + }); + + it('should handle sslmode=require', () => { + process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=require'; + + const { database } = getEnv(); + + expect(database.config.kysely).toMatchObject({ ssl: {} }); + }); + + it('should handle sslmode=prefer', () => { + process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=prefer'; + + const { database } = getEnv(); + + expect(database.config.kysely).toMatchObject({ ssl: {} }); + }); + + it('should handle sslmode=verify-ca', () => { + process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-ca'; + + const { database } = getEnv(); + + expect(database.config.kysely).toMatchObject({ ssl: {} }); + }); + + it('should handle sslmode=verify-full', () => { + process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-full'; + + const { database } = getEnv(); + + expect(database.config.kysely).toMatchObject({ ssl: {} }); + }); + + it('should handle sslmode=no-verify', () => { + process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=no-verify'; + + const { database } = getEnv(); + + expect(database.config.kysely).toMatchObject({ ssl: { rejectUnauthorized: false } }); + }); + + it('should handle ssl=true', () => { + process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?ssl=true'; + + const { database } = getEnv(); + + expect(database.config.kysely).toMatchObject({ ssl: true }); + }); + + it('should reject invalid ssl', () => { + process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?ssl=invalid'; + + expect(() => getEnv()).toThrowError('Invalid ssl option: invalid'); + }); + + it('should handle socket: URLs', () => { + process.env.DB_URL = 'socket:/run/postgresql?db=database1'; + + const { database } = getEnv(); + + expect(database.config.kysely).toMatchObject({ + host: '/run/postgresql', + database: 'database1', + }); + }); + + it('should handle sockets in postgres: URLs', () => { + process.env.DB_URL = 'postgres:///database2?host=/path/to/socket'; + + const { database } = getEnv(); + + expect(database.config.kysely).toMatchObject({ + host: '/path/to/socket', + database: 'database2', + }); + }); }); describe('redis', () => { diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index d78e473da2..2d5f2bc2e2 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -5,21 +5,41 @@ import { plainToInstance } from 'class-transformer'; import { validateSync } from 'class-validator'; import { Request, Response } from 'express'; import { RedisOptions } from 'ioredis'; -import { KyselyConfig } from 'kysely'; -import { PostgresJSDialect } from 'kysely-postgres-js'; import { CLS_ID, ClsModuleOptions } from 'nestjs-cls'; import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces'; import { join, resolve } from 'node:path'; -import postgres, { Notice } from 'postgres'; +import { parse } from 'pg-connection-string'; +import { Notice } from 'postgres'; import { citiesFile, excludePaths, IWorker } from 'src/constants'; import { Telemetry } from 'src/decorators'; import { EnvDto } from 'src/dtos/env.dto'; -import { ImmichEnvironment, ImmichHeader, ImmichTelemetry, ImmichWorker, LogLevel } from 'src/enum'; -import { DatabaseConnectionParams, DatabaseExtension, VectorExtension } from 'src/interfaces/database.interface'; -import { QueueName } from 'src/interfaces/job.interface'; +import { + DatabaseExtension, + ImmichEnvironment, + ImmichHeader, + ImmichTelemetry, + ImmichWorker, + LogLevel, + QueueName, +} from 'src/enum'; +import { DatabaseConnectionParams, VectorExtension } from 'src/types'; import { setDifference } from 'src/utils/set'; import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js'; +type Ssl = 'require' | 'allow' | 'prefer' | 'verify-full' | boolean | object; +type PostgresConnectionConfig = { + host?: string; + password?: string; + user?: string; + port?: number; + database?: string; + client_encoding?: string; + ssl?: Ssl; + application_name?: string; + fallback_application_name?: string; + options?: string; +}; + export interface EnvData { host?: string; port: number; @@ -53,7 +73,7 @@ export interface EnvData { }; database: { - config: { typeorm: PostgresConnectionOptions & DatabaseConnectionParams; kysely: KyselyConfig }; + config: { typeorm: PostgresConnectionOptions & DatabaseConnectionParams; kysely: PostgresConnectionConfig }; skipMigrations: boolean; vectorExtension: VectorExtension; }; @@ -124,6 +144,9 @@ const asSet = (value: string | undefined, defaults: T[]) => { return new Set(values.length === 0 ? defaults : (values as T[])); }; +const isValidSsl = (ssl?: string | boolean | object): ssl is Ssl => + typeof ssl !== 'string' || ssl === 'require' || ssl === 'allow' || ssl === 'prefer' || ssl === 'verify-full'; + const getEnv = (): EnvData => { const dto = plainToInstance(EnvDto, process.env); const errors = validateSync(dto); @@ -185,7 +208,33 @@ const getEnv = (): EnvData => { } } + const parts = { + connectionType: 'parts', + host: dto.DB_HOSTNAME || 'database', + port: dto.DB_PORT || 5432, + username: dto.DB_USERNAME || 'postgres', + password: dto.DB_PASSWORD || 'postgres', + database: dto.DB_DATABASE_NAME || 'immich', + } as const; + + let parsedOptions: PostgresConnectionConfig = parts; + if (dto.DB_URL) { + const parsed = parse(dto.DB_URL); + if (!isValidSsl(parsed.ssl)) { + throw new Error(`Invalid ssl option: ${parsed.ssl}`); + } + + parsedOptions = { + ...parsed, + ssl: parsed.ssl, + host: parsed.host ?? undefined, + port: parsed.port ? Number(parsed.port) : undefined, + database: parsed.database ?? undefined, + }; + } + const driverOptions = { + ...parsedOptions, onnotice: (notice: Notice) => { if (notice['severity'] !== 'NOTICE') { console.warn('Postgres notice:', notice); @@ -206,17 +255,11 @@ const getEnv = (): EnvData => { serialize: (value: number) => value.toString(), }, }, + connection: { + TimeZone: 'UTC', + }, }; - const parts = { - connectionType: 'parts', - host: dto.DB_HOSTNAME || 'database', - port: dto.DB_PORT || 5432, - username: dto.DB_USERNAME || 'postgres', - password: dto.DB_PASSWORD || 'postgres', - database: dto.DB_DATABASE_NAME || 'immich', - } as const; - return { host: dto.IMMICH_HOST, port: dto.IMMICH_PORT || 2283, @@ -282,21 +325,7 @@ const getEnv = (): EnvData => { parseInt8: true, ...(databaseUrl ? { connectionType: 'url', url: databaseUrl } : parts), }, - kysely: { - dialect: new PostgresJSDialect({ - postgres: databaseUrl ? postgres(databaseUrl, driverOptions) : postgres({ ...parts, ...driverOptions }), - }), - log(event) { - if (event.level === 'error') { - console.error('Query failed :', { - durationMs: event.queryDurationMillis, - error: event.error, - sql: event.query.sql, - params: event.query.parameters, - }); - } - }, - }, + kysely: driverOptions, }, skipMigrations: dto.DB_SKIP_MIGRATIONS ?? false, diff --git a/server/src/repositories/crypto.repository.ts b/server/src/repositories/crypto.repository.ts index ee25609fec..e471ccb031 100644 --- a/server/src/repositories/crypto.repository.ts +++ b/server/src/repositories/crypto.repository.ts @@ -2,11 +2,10 @@ import { Injectable } from '@nestjs/common'; import { compareSync, hash } from 'bcrypt'; import { createHash, createPublicKey, createVerify, randomBytes, randomUUID } from 'node:crypto'; import { createReadStream } from 'node:fs'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; @Injectable() -export class CryptoRepository implements ICryptoRepository { - randomUUID() { +export class CryptoRepository { + randomUUID(): string { return randomUUID(); } diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index f7d52efd7a..c4aeb74028 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -1,37 +1,27 @@ import { Injectable } from '@nestjs/common'; -import { InjectDataSource } from '@nestjs/typeorm'; import AsyncLock from 'async-lock'; -import { Kysely, sql } from 'kysely'; +import { Kysely, sql, Transaction } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import semver from 'semver'; -import { POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants'; +import { EXTENSION_NAMES, POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants'; import { DB } from 'src/db'; -import { - DatabaseExtension, - DatabaseLock, - EXTENSION_NAMES, - ExtensionVersion, - IDatabaseRepository, - VectorExtension, - VectorIndex, - VectorUpdateResult, -} from 'src/interfaces/database.interface'; +import { GenerateSql } from 'src/decorators'; +import { DatabaseExtension, DatabaseLock, VectorIndex } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; -import { UPSERT_COLUMNS } from 'src/utils/database'; +import { ExtensionVersion, VectorExtension, VectorUpdateResult } from 'src/types'; import { isValidInteger } from 'src/validation'; -import { DataSource, EntityManager, EntityMetadata, QueryRunner } from 'typeorm'; +import { DataSource } from 'typeorm'; @Injectable() -export class DatabaseRepository implements IDatabaseRepository { +export class DatabaseRepository { private vectorExtension: VectorExtension; private readonly asyncLock = new AsyncLock(); constructor( @InjectKysely() private db: Kysely, - @InjectDataSource() private dataSource: DataSource, private logger: LoggingRepository, - configRepository: ConfigRepository, + private configRepository: ConfigRepository, ) { this.vectorExtension = configRepository.getEnv().database.vectorExtension; this.logger.setContext(DatabaseRepository.name); @@ -41,43 +31,24 @@ export class DatabaseRepository implements IDatabaseRepository { await this.db.destroy(); } - init() { - for (const metadata of this.dataSource.entityMetadatas) { - const table = metadata.tableName as keyof DB; - UPSERT_COLUMNS[table] = this.getUpsertColumns(metadata); - } - } - - async reconnect() { - try { - if (this.dataSource.isInitialized) { - await this.dataSource.destroy(); - } - const { isInitialized } = await this.dataSource.initialize(); - return isInitialized; - } catch (error) { - this.logger.error(`Database connection failed: ${error}`); - return false; - } - } - + @GenerateSql({ params: [DatabaseExtension.VECTORS] }) async getExtensionVersion(extension: DatabaseExtension): Promise { - const [res]: ExtensionVersion[] = await this.dataSource.query( - `SELECT default_version as "availableVersion", installed_version as "installedVersion" + const { rows } = await sql` + SELECT default_version as "availableVersion", installed_version as "installedVersion" FROM pg_available_extensions - WHERE name = $1`, - [extension], - ); - return res ?? { availableVersion: null, installedVersion: null }; + WHERE name = ${extension} + `.execute(this.db); + return rows[0] ?? { availableVersion: null, installedVersion: null }; } getExtensionVersionRange(extension: VectorExtension): string { return extension === DatabaseExtension.VECTORS ? VECTORS_VERSION_RANGE : VECTOR_VERSION_RANGE; } + @GenerateSql() async getPostgresVersion(): Promise { - const [{ server_version: version }] = await this.dataSource.query(`SHOW server_version`); - return version; + const { rows } = await sql<{ server_version: string }>`SHOW server_version`.execute(this.db); + return rows[0].server_version; } getPostgresVersionRange(): string { @@ -85,7 +56,7 @@ export class DatabaseRepository implements IDatabaseRepository { } async createExtension(extension: DatabaseExtension): Promise { - await this.dataSource.query(`CREATE EXTENSION IF NOT EXISTS ${extension}`); + await sql`CREATE EXTENSION IF NOT EXISTS ${sql.raw(extension)}`.execute(this.db); } async updateVectorExtension(extension: VectorExtension, targetVersion?: string): Promise { @@ -101,23 +72,23 @@ export class DatabaseRepository implements IDatabaseRepository { const isVectors = extension === DatabaseExtension.VECTORS; let restartRequired = false; - await this.dataSource.manager.transaction(async (manager) => { - await this.setSearchPath(manager); + await this.db.transaction().execute(async (tx) => { + await this.setSearchPath(tx); if (isVectors && installedVersion === '0.1.1') { - await this.setExtVersion(manager, DatabaseExtension.VECTORS, '0.1.11'); + await this.setExtVersion(tx, DatabaseExtension.VECTORS, '0.1.11'); } const isSchemaUpgrade = semver.satisfies(installedVersion, '0.1.1 || 0.1.11'); if (isSchemaUpgrade && isVectors) { - await this.updateVectorsSchema(manager); + await this.updateVectorsSchema(tx); } - await manager.query(`ALTER EXTENSION ${extension} UPDATE TO '${targetVersion}'`); + await sql`ALTER EXTENSION ${sql.raw(extension)} UPDATE TO ${sql.lit(targetVersion)}`.execute(tx); const diff = semver.diff(installedVersion, targetVersion); if (isVectors && diff && ['minor', 'major'].includes(diff)) { - await manager.query('SELECT pgvectors_upgrade()'); + await sql`SELECT pgvectors_upgrade()`.execute(tx); restartRequired = true; } else { await this.reindex(VectorIndex.CLIP); @@ -130,7 +101,7 @@ export class DatabaseRepository implements IDatabaseRepository { async reindex(index: VectorIndex): Promise { try { - await this.dataSource.query(`REINDEX INDEX ${index}`); + await sql`REINDEX INDEX ${sql.raw(index)}`.execute(this.db); } catch (error) { if (this.vectorExtension !== DatabaseExtension.VECTORS) { throw error; @@ -139,29 +110,34 @@ export class DatabaseRepository implements IDatabaseRepository { const table = await this.getIndexTable(index); const dimSize = await this.getDimSize(table); - await this.dataSource.manager.transaction(async (manager) => { - await this.setSearchPath(manager); - await manager.query(`DROP INDEX IF EXISTS ${index}`); - await manager.query(`ALTER TABLE ${table} ALTER COLUMN embedding SET DATA TYPE real[]`); - await manager.query(`ALTER TABLE ${table} ALTER COLUMN embedding SET DATA TYPE vector(${dimSize})`); - await manager.query(`SET vectors.pgvector_compatibility=on`); - await manager.query(` - CREATE INDEX IF NOT EXISTS ${index} ON ${table} + await this.db.transaction().execute(async (tx) => { + await this.setSearchPath(tx); + await sql`DROP INDEX IF EXISTS ${sql.raw(index)}`.execute(tx); + await sql`ALTER TABLE ${sql.raw(table)} ALTER COLUMN embedding SET DATA TYPE real[]`.execute(tx); + await sql`ALTER TABLE ${sql.raw(table)} ALTER COLUMN embedding SET DATA TYPE vector(${sql.raw(String(dimSize))})`.execute( + tx, + ); + await sql`SET vectors.pgvector_compatibility=on`.execute(tx); + await sql` + CREATE INDEX IF NOT EXISTS ${sql.raw(index)} ON ${sql.raw(table)} USING hnsw (embedding vector_cosine_ops) - WITH (ef_construction = 300, m = 16)`); + WITH (ef_construction = 300, m = 16) + `.execute(tx); }); } } + @GenerateSql({ params: [VectorIndex.CLIP] }) async shouldReindex(name: VectorIndex): Promise { if (this.vectorExtension !== DatabaseExtension.VECTORS) { return false; } try { - const query = `SELECT idx_status FROM pg_vector_index_stat WHERE indexname = $1`; - const res = await this.dataSource.query(query, [name]); - return res[0]?.['idx_status'] === 'UPGRADE'; + const { rows } = await sql<{ + idx_status: string; + }>`SELECT idx_status FROM pg_vector_index_stat WHERE indexname = ${name}`.execute(this.db); + return rows[0]?.idx_status === 'UPGRADE'; } catch (error) { const message: string = (error as any).message; if (message.includes('index is not existing')) { @@ -173,44 +149,45 @@ export class DatabaseRepository implements IDatabaseRepository { } } - private async setSearchPath(manager: EntityManager): Promise { - await manager.query(`SET search_path TO "$user", public, vectors`); + private async setSearchPath(tx: Transaction): Promise { + await sql`SET search_path TO "$user", public, vectors`.execute(tx); } - private async setExtVersion(manager: EntityManager, extName: DatabaseExtension, version: string): Promise { - const query = `UPDATE pg_catalog.pg_extension SET extversion = $1 WHERE extname = $2`; - await manager.query(query, [version, extName]); + private async setExtVersion(tx: Transaction, extName: DatabaseExtension, version: string): Promise { + await sql`UPDATE pg_catalog.pg_extension SET extversion = ${version} WHERE extname = ${extName}`.execute(tx); } private async getIndexTable(index: VectorIndex): Promise { - const tableQuery = `SELECT relname FROM pg_stat_all_indexes WHERE indexrelname = $1`; - const [res]: { relname: string | null }[] = await this.dataSource.manager.query(tableQuery, [index]); - const table = res?.relname; + const { rows } = await sql<{ + relname: string | null; + }>`SELECT relname FROM pg_stat_all_indexes WHERE indexrelname = ${index}`.execute(this.db); + const table = rows[0]?.relname; if (!table) { throw new Error(`Could not find table for index ${index}`); } return table; } - private async updateVectorsSchema(manager: EntityManager): Promise { + private async updateVectorsSchema(tx: Transaction): Promise { const extension = DatabaseExtension.VECTORS; - await manager.query(`CREATE SCHEMA IF NOT EXISTS ${extension}`); - await manager.query('UPDATE pg_catalog.pg_extension SET extrelocatable = true WHERE extname = $1', [extension]); - await manager.query('ALTER EXTENSION vectors SET SCHEMA vectors'); - await manager.query('UPDATE pg_catalog.pg_extension SET extrelocatable = false WHERE extname = $1', [extension]); + await sql`CREATE SCHEMA IF NOT EXISTS ${extension}`.execute(tx); + await sql`UPDATE pg_catalog.pg_extension SET extrelocatable = true WHERE extname = ${extension}`.execute(tx); + await sql`ALTER EXTENSION vectors SET SCHEMA vectors`.execute(tx); + await sql`UPDATE pg_catalog.pg_extension SET extrelocatable = false WHERE extname = ${extension}`.execute(tx); } private async getDimSize(table: string, column = 'embedding'): Promise { - const res = await this.dataSource.query(` + const { rows } = await sql<{ dimsize: number }>` SELECT atttypmod as dimsize FROM pg_attribute f JOIN pg_class c ON c.oid = f.attrelid WHERE c.relkind = 'r'::char AND f.attnum > 0 - AND c.relname = '${table}' - AND f.attname = '${column}'`); + AND c.relname = ${table} + AND f.attname = '${column}' + `.execute(this.db); - const dimSize = res[0]['dimsize']; + const dimSize = rows[0]?.dimsize; if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) { throw new Error(`Could not retrieve dimension size`); } @@ -218,31 +195,34 @@ export class DatabaseRepository implements IDatabaseRepository { } async runMigrations(options?: { transaction?: 'all' | 'none' | 'each' }): Promise { - await this.dataSource.runMigrations(options); + const { database } = this.configRepository.getEnv(); + const dataSource = new DataSource(database.config.typeorm); + + this.logger.log('Running migrations, this may take a while'); + + await dataSource.initialize(); + await dataSource.runMigrations(options); + await dataSource.destroy(); } async withLock(lock: DatabaseLock, callback: () => Promise): Promise { let res; await this.asyncLock.acquire(DatabaseLock[lock], async () => { - const queryRunner = this.dataSource.createQueryRunner(); - try { - await this.acquireLock(lock, queryRunner); - res = await callback(); - } finally { + await this.db.connection().execute(async (connection) => { try { - await this.releaseLock(lock, queryRunner); + await this.acquireLock(lock, connection); + res = await callback(); } finally { - await queryRunner.release(); + await this.releaseLock(lock, connection); } - } + }); }); return res as R; } - async tryLock(lock: DatabaseLock): Promise { - const queryRunner = this.dataSource.createQueryRunner(); - return await this.acquireTryLock(lock, queryRunner); + tryLock(lock: DatabaseLock): Promise { + return this.db.connection().execute(async (connection) => this.acquireTryLock(lock, connection)); } isBusy(lock: DatabaseLock): boolean { @@ -253,22 +233,18 @@ export class DatabaseRepository implements IDatabaseRepository { await this.asyncLock.acquire(DatabaseLock[lock], () => {}); } - private async acquireLock(lock: DatabaseLock, queryRunner: QueryRunner): Promise { - return queryRunner.query('SELECT pg_advisory_lock($1)', [lock]); + private async acquireLock(lock: DatabaseLock, connection: Kysely): Promise { + await sql`SELECT pg_advisory_lock(${lock})`.execute(connection); } - private async acquireTryLock(lock: DatabaseLock, queryRunner: QueryRunner): Promise { - const lockResult = await queryRunner.query('SELECT pg_try_advisory_lock($1)', [lock]); - return lockResult[0].pg_try_advisory_lock; + private async acquireTryLock(lock: DatabaseLock, connection: Kysely): Promise { + const { rows } = await sql<{ + pg_try_advisory_lock: boolean; + }>`SELECT pg_try_advisory_lock(${lock})`.execute(connection); + return rows[0].pg_try_advisory_lock; } - private async releaseLock(lock: DatabaseLock, queryRunner: QueryRunner): Promise { - return queryRunner.query('SELECT pg_advisory_unlock($1)', [lock]); - } - - private getUpsertColumns(metadata: EntityMetadata) { - return Object.fromEntries( - metadata.ownColumns.map((column) => [column.propertyName, sql`excluded.${sql.ref(column.propertyName)}`]), - ) as any; + private async releaseLock(lock: DatabaseLock, connection: Kysely): Promise { + await sql`SELECT pg_advisory_unlock(${lock})`.execute(connection); } } diff --git a/server/src/repositories/download.repository.ts b/server/src/repositories/download.repository.ts new file mode 100644 index 0000000000..c9c62c90ce --- /dev/null +++ b/server/src/repositories/download.repository.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; +import { Kysely } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; +import { DB } from 'src/db'; +import { anyUuid } from 'src/utils/database'; + +const builder = (db: Kysely) => + db + .selectFrom('assets') + .innerJoin('exif', 'assetId', 'id') + .select(['assets.id', 'assets.livePhotoVideoId', 'exif.fileSizeInByte as size']) + .where('assets.deletedAt', 'is', null); + +@Injectable() +export class DownloadRepository { + constructor(@InjectKysely() private db: Kysely) {} + + downloadAssetIds(ids: string[]) { + return builder(this.db).where('assets.id', '=', anyUuid(ids)).stream(); + } + + downloadMotionAssetIds(ids: string[]) { + return builder(this.db).select(['assets.originalPath']).where('assets.id', '=', anyUuid(ids)).stream(); + } + + downloadAlbumId(albumId: string) { + return builder(this.db) + .innerJoin('albums_assets_assets', 'assets.id', 'albums_assets_assets.assetsId') + .where('albums_assets_assets.albumsId', '=', albumId) + .stream(); + } + + downloadUserId(userId: string) { + return builder(this.db).where('assets.ownerId', '=', userId).where('assets.isVisible', '=', true).stream(); + } +} diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index a443e0ed83..3156804d09 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -10,21 +10,15 @@ import { import { ClassConstructor } from 'class-transformer'; import _ from 'lodash'; import { Server, Socket } from 'socket.io'; +import { SystemConfig } from 'src/config'; import { EventConfig } from 'src/decorators'; -import { ImmichWorker, MetadataKey } from 'src/enum'; -import { - ArgsOf, - ClientEventMap, - EmitEvent, - EmitHandler, - EventItem, - IEventRepository, - serverEvents, - ServerEvents, -} from 'src/interfaces/event.interface'; +import { AssetResponseDto } from 'src/dtos/asset-response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; +import { ImmichWorker, MetadataKey, QueueName } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; -import { AuthService } from 'src/services/auth.service'; +import { JobItem } from 'src/types'; import { handlePromiseError } from 'src/utils/misc'; type EmitHandlers = Partial<{ [T in EmitEvent]: Array> }>; @@ -37,14 +31,99 @@ type Item = { label: string; }; +type EventMap = { + // app events + 'app.bootstrap': []; + 'app.shutdown': []; + + 'config.init': [{ newConfig: SystemConfig }]; + // config events + 'config.update': [ + { + newConfig: SystemConfig; + oldConfig: SystemConfig; + }, + ]; + 'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; + + // album events + 'album.update': [{ id: string; recipientIds: string[] }]; + 'album.invite': [{ id: string; userId: string }]; + + // asset events + 'asset.tag': [{ assetId: string }]; + 'asset.untag': [{ assetId: string }]; + 'asset.hide': [{ assetId: string; userId: string }]; + 'asset.show': [{ assetId: string; userId: string }]; + 'asset.trash': [{ assetId: string; userId: string }]; + 'asset.delete': [{ assetId: string; userId: string }]; + + // asset bulk events + 'assets.trash': [{ assetIds: string[]; userId: string }]; + 'assets.delete': [{ assetIds: string[]; userId: string }]; + 'assets.restore': [{ assetIds: string[]; userId: string }]; + + 'job.start': [QueueName, JobItem]; + + // session events + 'session.delete': [{ sessionId: string }]; + + // stack events + 'stack.create': [{ stackId: string; userId: string }]; + 'stack.update': [{ stackId: string; userId: string }]; + 'stack.delete': [{ stackId: string; userId: string }]; + + // stack bulk events + 'stacks.delete': [{ stackIds: string[]; userId: string }]; + + // user events + 'user.signup': [{ notify: boolean; id: string; tempPassword?: string }]; + + // websocket events + 'websocket.connect': [{ userId: string }]; +}; + +export const serverEvents = ['config.update'] as const; +export type ServerEvents = (typeof serverEvents)[number]; + +export type EmitEvent = keyof EventMap; +export type EmitHandler = (...args: ArgsOf) => Promise | void; +export type ArgOf = EventMap[T][0]; +export type ArgsOf = EventMap[T]; + +export interface ClientEventMap { + on_upload_success: [AssetResponseDto]; + on_user_delete: [string]; + on_asset_delete: [string]; + on_asset_trash: [string[]]; + on_asset_update: [AssetResponseDto]; + on_asset_hidden: [string]; + on_asset_restore: [string[]]; + on_asset_stack_update: string[]; + on_person_thumbnail: [string]; + on_server_version: [ServerVersionResponseDto]; + on_config_update: []; + on_new_release: [ReleaseNotification]; + on_session_delete: [string]; +} + +export type EventItem = { + event: T; + handler: EmitHandler; + server: boolean; +}; + +export type AuthFn = (client: Socket) => Promise; + @WebSocketGateway({ cors: true, path: '/api/socket.io', transports: ['websocket'], }) @Injectable() -export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, IEventRepository { +export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit { private emitHandlers: EmitHandlers = {}; + private authFn?: AuthFn; @WebSocketServer() private server?: Server; @@ -122,11 +201,7 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect async handleConnection(client: Socket) { try { this.logger.log(`Websocket Connect: ${client.id}`); - const auth = await this.moduleRef.get(AuthService).authenticate({ - headers: client.request.headers, - queryParams: {}, - metadata: { adminRoute: false, sharedLinkRoute: false, uri: '/api/socket.io' }, - }); + const auth = await this.authenticate(client); await client.join(auth.user.id); if (auth.session) { await client.join(auth.session.id); @@ -182,4 +257,16 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect this.logger.debug(`Server event: ${event} (send)`); this.server?.serverSideEmit(event, ...args); } + + setAuthFn(fn: (client: Socket) => Promise) { + this.authFn = fn; + } + + private async authenticate(client: Socket) { + if (!this.authFn) { + throw new Error('Auth function not set'); + } + + return this.authFn(client); + } } diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index b54c69e117..8c262edcde 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -1,23 +1,3 @@ -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IDatabaseRepository } from 'src/interfaces/database.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { IJobRepository } from 'src/interfaces/job.interface'; -import { ILibraryRepository } from 'src/interfaces/library.interface'; -import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; -import { IMoveRepository } from 'src/interfaces/move.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { IProcessRepository } from 'src/interfaces/process.interface'; -import { ISearchRepository } from 'src/interfaces/search.interface'; -import { ISessionRepository } from 'src/interfaces/session.interface'; -import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; -import { IStackRepository } from 'src/interfaces/stack.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { ITagRepository } from 'src/interfaces/tag.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; @@ -29,6 +9,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 { DownloadRepository } from 'src/repositories/download.repository'; import { EventRepository } from 'src/repositories/event.repository'; import { JobRepository } from 'src/repositories/job.repository'; import { LibraryRepository } from 'src/repositories/library.repository'; @@ -50,6 +31,7 @@ import { SessionRepository } from 'src/repositories/session.repository'; import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; import { StackRepository } from 'src/repositories/stack.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; +import { SyncRepository } from 'src/repositories/sync.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { TagRepository } from 'src/repositories/tag.repository'; import { TelemetryRepository } from 'src/repositories/telemetry.repository'; @@ -61,44 +43,43 @@ import { ViewRepository } from 'src/repositories/view-repository'; export const repositories = [ AccessRepository, ActivityRepository, + AlbumRepository, AlbumUserRepository, AuditRepository, ApiKeyRepository, + AssetRepository, ConfigRepository, CronRepository, + CryptoRepository, + DatabaseRepository, + DownloadRepository, + EventRepository, + JobRepository, + LibraryRepository, LoggingRepository, + MachineLearningRepository, MapRepository, MediaRepository, MemoryRepository, MetadataRepository, + MoveRepository, NotificationRepository, OAuthRepository, + PartnerRepository, + PersonRepository, + ProcessRepository, + SearchRepository, + SessionRepository, ServerInfoRepository, + SharedLinkRepository, + StackRepository, + StorageRepository, + SyncRepository, + SystemMetadataRepository, + TagRepository, TelemetryRepository, TrashRepository, + UserRepository, ViewRepository, VersionHistoryRepository, ]; - -export const providers = [ - { provide: IAlbumRepository, useClass: AlbumRepository }, - { provide: IAssetRepository, useClass: AssetRepository }, - { provide: ICryptoRepository, useClass: CryptoRepository }, - { provide: IDatabaseRepository, useClass: DatabaseRepository }, - { provide: IEventRepository, useClass: EventRepository }, - { provide: IJobRepository, useClass: JobRepository }, - { provide: ILibraryRepository, useClass: LibraryRepository }, - { provide: IMachineLearningRepository, useClass: MachineLearningRepository }, - { provide: IMoveRepository, useClass: MoveRepository }, - { provide: IPartnerRepository, useClass: PartnerRepository }, - { provide: IPersonRepository, useClass: PersonRepository }, - { provide: IProcessRepository, useClass: ProcessRepository }, - { provide: ISearchRepository, useClass: SearchRepository }, - { provide: ISessionRepository, useClass: SessionRepository }, - { provide: ISharedLinkRepository, useClass: SharedLinkRepository }, - { provide: IStackRepository, useClass: StackRepository }, - { provide: IStorageRepository, useClass: StorageRepository }, - { provide: ISystemMetadataRepository, useClass: SystemMetadataRepository }, - { provide: ITagRepository, useClass: TagRepository }, - { provide: IUserRepository, useClass: UserRepository }, -]; diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index 9a5bf20df6..fd9f4c5363 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -1,26 +1,15 @@ import { getQueueToken } from '@nestjs/bullmq'; -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ModuleRef, Reflector } from '@nestjs/core'; import { JobsOptions, Queue, Worker } from 'bullmq'; import { ClassConstructor } from 'class-transformer'; import { setTimeout } from 'node:timers/promises'; import { JobConfig } from 'src/decorators'; -import { MetadataKey } from 'src/enum'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { - IEntityJob, - IJobRepository, - JobCounts, - JobItem, - JobName, - JobOf, - JobStatus, - QueueCleanType, - QueueName, - QueueStatus, -} from 'src/interfaces/job.interface'; +import { JobName, JobStatus, MetadataKey, QueueCleanType, QueueName } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; +import { EventRepository } from 'src/repositories/event.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; +import { IEntityJob, JobCounts, JobItem, JobOf, QueueStatus } from 'src/types'; import { getKeyByValue, getMethodNames, ImmichStartupError } from 'src/utils/misc'; type JobMapItem = { @@ -31,14 +20,14 @@ type JobMapItem = { }; @Injectable() -export class JobRepository implements IJobRepository { +export class JobRepository { private workers: Partial> = {}; private handlers: Partial> = {}; constructor( private moduleRef: ModuleRef, private configRepository: ConfigRepository, - @Inject(IEventRepository) private eventRepository: IEventRepository, + private eventRepository: EventRepository, private logger: LoggingRepository, ) { this.logger.setContext(JobRepository.name); diff --git a/server/src/repositories/library.repository.ts b/server/src/repositories/library.repository.ts index 0e1ec94c32..efa6e880d1 100644 --- a/server/src/repositories/library.repository.ts +++ b/server/src/repositories/library.repository.ts @@ -1,98 +1,71 @@ import { Injectable } from '@nestjs/common'; -import { ExpressionBuilder, Insertable, Kysely, sql, Updateable } from 'kysely'; -import { jsonObjectFrom } from 'kysely/helpers/postgres'; +import { Insertable, Kysely, sql, Updateable } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { DB, Libraries } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { LibraryStatsResponseDto } from 'src/dtos/library.dto'; -import { LibraryEntity } from 'src/entities/library.entity'; import { AssetType } from 'src/enum'; -import { ILibraryRepository } from 'src/interfaces/library.interface'; -const userColumns = [ - 'users.id', - 'users.email', - 'users.createdAt', - 'users.profileImagePath', - 'users.isAdmin', - 'users.shouldChangePassword', - 'users.deletedAt', - 'users.oauthId', - 'users.updatedAt', - 'users.storageLabel', - 'users.name', - 'users.quotaSizeInBytes', - 'users.quotaUsageInBytes', - 'users.status', - 'users.profileChangedAt', -] as const; - -const withOwner = (eb: ExpressionBuilder) => { - return jsonObjectFrom(eb.selectFrom('users').whereRef('users.id', '=', 'libraries.ownerId').select(userColumns)).as( - 'owner', - ); -}; +export enum AssetSyncResult { + DO_NOTHING, + UPDATE, + OFFLINE, + CHECK_OFFLINE, +} @Injectable() -export class LibraryRepository implements ILibraryRepository { +export class LibraryRepository { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID] }) - get(id: string, withDeleted = false): Promise { + get(id: string, withDeleted = false) { return this.db .selectFrom('libraries') .selectAll('libraries') - .select(withOwner) .where('libraries.id', '=', id) .$if(!withDeleted, (qb) => qb.where('libraries.deletedAt', 'is', null)) - .executeTakeFirst() as Promise; + .executeTakeFirst(); } @GenerateSql({ params: [] }) - getAll(withDeleted = false): Promise { + getAll(withDeleted = false) { return this.db .selectFrom('libraries') .selectAll('libraries') - .select(withOwner) .orderBy('createdAt', 'asc') .$if(!withDeleted, (qb) => qb.where('libraries.deletedAt', 'is', null)) - .execute() as unknown as Promise; + .execute(); } @GenerateSql() - getAllDeleted(): Promise { + getAllDeleted() { return this.db .selectFrom('libraries') .selectAll('libraries') - .select(withOwner) .where('libraries.deletedAt', 'is not', null) .orderBy('createdAt', 'asc') - .execute() as unknown as Promise; + .execute(); } - create(library: Insertable): Promise { - return this.db - .insertInto('libraries') - .values(library) - .returningAll() - .executeTakeFirstOrThrow() as Promise; + create(library: Insertable) { + return this.db.insertInto('libraries').values(library).returningAll().executeTakeFirstOrThrow(); } - async delete(id: string): Promise { + async delete(id: string) { await this.db.deleteFrom('libraries').where('libraries.id', '=', id).execute(); } - async softDelete(id: string): Promise { + async softDelete(id: string) { await this.db.updateTable('libraries').set({ deletedAt: new Date() }).where('libraries.id', '=', id).execute(); } - update(id: string, library: Updateable): Promise { + update(id: string, library: Updateable) { return this.db .updateTable('libraries') .set(library) .where('libraries.id', '=', id) .returningAll() - .executeTakeFirstOrThrow() as Promise; + .executeTakeFirstOrThrow(); } @GenerateSql({ params: [DummyValue.UUID] }) @@ -138,4 +111,8 @@ export class LibraryRepository implements ILibraryRepository { total: Number(stats.photos) + Number(stats.videos), }; } + + streamAssetIds(libraryId: string) { + return this.db.selectFrom('assets').select(['id']).where('libraryId', '=', libraryId).stream(); + } } diff --git a/server/src/repositories/logging.repository.spec.ts b/server/src/repositories/logging.repository.spec.ts index 11fa19e48b..393eeb9496 100644 --- a/server/src/repositories/logging.repository.spec.ts +++ b/server/src/repositories/logging.repository.spec.ts @@ -1,14 +1,14 @@ import { ClsService } from 'nestjs-cls'; import { ImmichWorker } from 'src/enum'; -import { LoggingRepository } from 'src/repositories/logging.repository'; -import { IConfigRepository } from 'src/types'; -import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; +import { ConfigRepository } from 'src/repositories/config.repository'; +import { LoggingRepository, MyConsoleLogger } from 'src/repositories/logging.repository'; +import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; import { Mocked } from 'vitest'; describe(LoggingRepository.name, () => { let sut: LoggingRepository; - let configMock: Mocked; + let configMock: Mocked; let clsMock: Mocked; beforeEach(() => { @@ -18,23 +18,25 @@ describe(LoggingRepository.name, () => { } as unknown as Mocked; }); - describe('formatContext', () => { - it('should use colors', () => { - configMock.getEnv.mockReturnValue(mockEnvData({ noColor: false })); + describe(MyConsoleLogger.name, () => { + describe('formatContext', () => { + it('should use colors', () => { + sut = new LoggingRepository(clsMock, configMock); + sut.setAppName(ImmichWorker.API); - sut = new LoggingRepository(clsMock, configMock); - sut.setAppName(ImmichWorker.API); + const logger = new MyConsoleLogger(clsMock, { color: true }); - expect(sut['formatContext']('context')).toBe('\u001B[33m[Api:context]\u001B[39m '); - }); + expect(logger.formatContext('context')).toBe('\u001B[33m[Api:context]\u001B[39m '); + }); - it('should not use colors when noColor is true', () => { - configMock.getEnv.mockReturnValue(mockEnvData({ noColor: true })); + it('should not use colors when color is false', () => { + sut = new LoggingRepository(clsMock, configMock); + sut.setAppName(ImmichWorker.API); - sut = new LoggingRepository(clsMock, configMock); - sut.setAppName(ImmichWorker.API); + const logger = new MyConsoleLogger(clsMock, { color: false }); - expect(sut['formatContext']('context')).toBe('[Api:context] '); + expect(logger.formatContext('context')).toBe('[Api:context] '); + }); }); }); }); diff --git a/server/src/repositories/logging.repository.ts b/server/src/repositories/logging.repository.ts index 7ddae26a9d..aaf21a3d7c 100644 --- a/server/src/repositories/logging.repository.ts +++ b/server/src/repositories/logging.repository.ts @@ -5,6 +5,9 @@ import { Telemetry } from 'src/decorators'; import { LogLevel } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; +type LogDetails = any[]; +type LogFunction = () => string; + const LOG_LEVELS = [LogLevel.VERBOSE, LogLevel.DEBUG, LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]; enum LogColor { @@ -16,38 +19,26 @@ enum LogColor { CYAN_BRIGHT = 96, } -@Injectable({ scope: Scope.TRANSIENT }) -@Telemetry({ enabled: false }) -export class LoggingRepository extends ConsoleLogger { - private static logLevels: LogLevel[] = [LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]; - private noColor: boolean; +let appName: string | undefined; +let logLevels: LogLevel[] = [LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]; + +export class MyConsoleLogger extends ConsoleLogger { + private isColorEnabled: boolean; constructor( private cls: ClsService, - configRepository: ConfigRepository, + options?: { color?: boolean; context?: string }, ) { - super(LoggingRepository.name); - - const { noColor } = configRepository.getEnv(); - this.noColor = noColor; - } - - private static appName?: string = undefined; - - setAppName(name: string): void { - LoggingRepository.appName = name.charAt(0).toUpperCase() + name.slice(1); + super(options?.context || MyConsoleLogger.name); + this.isColorEnabled = options?.color || false; } isLevelEnabled(level: LogLevel) { - return isLogLevelEnabled(level, LoggingRepository.logLevels); + return isLogLevelEnabled(level, logLevels); } - setLogLevel(level: LogLevel | false): void { - LoggingRepository.logLevels = level ? LOG_LEVELS.slice(LOG_LEVELS.indexOf(level)) : []; - } - - protected formatContext(context: string): string { - let prefix = LoggingRepository.appName || ''; + formatContext(context: string): string { + let prefix = appName || ''; if (context) { prefix += (prefix ? ':' : '') + context; } @@ -74,6 +65,105 @@ export class LoggingRepository extends ConsoleLogger { }; private withColor(text: string, color: LogColor) { - return this.noColor ? text : `\u001B[${color}m${text}\u001B[39m`; + return this.isColorEnabled ? `\u001B[${color}m${text}\u001B[39m` : text; + } +} + +@Injectable({ scope: Scope.TRANSIENT }) +@Telemetry({ enabled: false }) +export class LoggingRepository { + private logger: MyConsoleLogger; + + constructor(cls: ClsService, configRepository: ConfigRepository) { + const { noColor } = configRepository.getEnv(); + this.logger = new MyConsoleLogger(cls, { context: LoggingRepository.name, color: !noColor }); + } + + setAppName(name: string): void { + appName = name.charAt(0).toUpperCase() + name.slice(1); + } + + setContext(context: string) { + this.logger.setContext(context); + } + + isLevelEnabled(level: LogLevel) { + return this.logger.isLevelEnabled(level); + } + + setLogLevel(level: LogLevel | false): void { + logLevels = level ? LOG_LEVELS.slice(LOG_LEVELS.indexOf(level)) : []; + } + + verbose(message: string, ...details: LogDetails) { + this.handleMessage(LogLevel.VERBOSE, message, details); + } + + verboseFn(message: LogFunction, ...details: LogDetails) { + this.handleFunction(LogLevel.VERBOSE, message, details); + } + + debug(message: string, ...details: LogDetails) { + this.handleMessage(LogLevel.DEBUG, message, details); + } + + debugFn(message: LogFunction, ...details: LogDetails) { + this.handleFunction(LogLevel.DEBUG, message, details); + } + + log(message: string, ...details: LogDetails) { + this.handleMessage(LogLevel.LOG, message, details); + } + + warn(message: string, ...details: LogDetails) { + this.handleMessage(LogLevel.WARN, message, details); + } + + error(message: string | Error, ...details: LogDetails) { + this.handleMessage(LogLevel.ERROR, message, details); + } + + fatal(message: string, ...details: LogDetails) { + this.handleMessage(LogLevel.FATAL, message, details); + } + + private handleFunction(level: LogLevel, message: LogFunction, details: LogDetails[]) { + if (this.logger.isLevelEnabled(level)) { + this.handleMessage(level, message(), details); + } + } + + private handleMessage(level: LogLevel, message: string | Error, details: LogDetails[]) { + switch (level) { + case LogLevel.VERBOSE: { + this.logger.verbose(message, ...details); + break; + } + + case LogLevel.DEBUG: { + this.logger.debug(message, ...details); + break; + } + + case LogLevel.LOG: { + this.logger.log(message, ...details); + break; + } + + case LogLevel.WARN: { + this.logger.warn(message, ...details); + break; + } + + case LogLevel.ERROR: { + this.logger.error(message, ...details); + break; + } + + case LogLevel.FATAL: { + this.logger.fatal(message, ...details); + break; + } + } } } diff --git a/server/src/repositories/machine-learning.repository.ts b/server/src/repositories/machine-learning.repository.ts index 6266314bfd..5e916c71f3 100644 --- a/server/src/repositories/machine-learning.repository.ts +++ b/server/src/repositories/machine-learning.repository.ts @@ -1,31 +1,135 @@ import { Injectable } from '@nestjs/common'; import { readFile } from 'node:fs/promises'; +import { MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME, MACHINE_LEARNING_PING_TIMEOUT } from 'src/constants'; import { CLIPConfig } from 'src/dtos/model-config.dto'; -import { - ClipTextualResponse, - ClipVisualResponse, - FaceDetectionOptions, - FacialRecognitionResponse, - IMachineLearningRepository, - MachineLearningRequest, - ModelPayload, - ModelTask, - ModelType, -} from 'src/interfaces/machine-learning.interface'; import { LoggingRepository } from 'src/repositories/logging.repository'; +export interface BoundingBox { + x1: number; + y1: number; + x2: number; + y2: number; +} + +export enum ModelTask { + FACIAL_RECOGNITION = 'facial-recognition', + SEARCH = 'clip', +} + +export enum ModelType { + DETECTION = 'detection', + PIPELINE = 'pipeline', + RECOGNITION = 'recognition', + TEXTUAL = 'textual', + VISUAL = 'visual', +} + +export type ModelPayload = { imagePath: string } | { text: string }; + +type ModelOptions = { modelName: string }; + +export type FaceDetectionOptions = ModelOptions & { minScore: number }; + +type VisualResponse = { imageHeight: number; imageWidth: number }; +export type ClipVisualRequest = { [ModelTask.SEARCH]: { [ModelType.VISUAL]: ModelOptions } }; +export type ClipVisualResponse = { [ModelTask.SEARCH]: string } & VisualResponse; + +export type ClipTextualRequest = { [ModelTask.SEARCH]: { [ModelType.TEXTUAL]: ModelOptions } }; +export type ClipTextualResponse = { [ModelTask.SEARCH]: string }; + +export type FacialRecognitionRequest = { + [ModelTask.FACIAL_RECOGNITION]: { + [ModelType.DETECTION]: ModelOptions & { options: { minScore: number } }; + [ModelType.RECOGNITION]: ModelOptions; + }; +}; + +export interface Face { + boundingBox: BoundingBox; + embedding: string; + score: number; +} + +export type FacialRecognitionResponse = { [ModelTask.FACIAL_RECOGNITION]: Face[] } & VisualResponse; +export type DetectedFaces = { faces: Face[] } & VisualResponse; +export type MachineLearningRequest = ClipVisualRequest | ClipTextualRequest | FacialRecognitionRequest; + @Injectable() -export class MachineLearningRepository implements IMachineLearningRepository { +export class MachineLearningRepository { + // Note that deleted URL's are not removed from this map (ie: they're leaked) + // Cleaning them up is low priority since there should be very few over a + // typical server uptime cycle + private urlAvailability: { + [url: string]: + | { + active: boolean; + lastChecked: number; + } + | undefined; + }; + constructor(private logger: LoggingRepository) { this.logger.setContext(MachineLearningRepository.name); + this.urlAvailability = {}; + } + + private setUrlAvailability(url: string, active: boolean) { + const current = this.urlAvailability[url]; + if (current?.active !== active) { + this.logger.verbose(`Setting ${url} ML server to ${active ? 'active' : 'inactive'}.`); + } + this.urlAvailability[url] = { + active, + lastChecked: Date.now(), + }; + } + + private async checkAvailability(url: string) { + let active = false; + try { + const response = await fetch(new URL('/ping', url), { + signal: AbortSignal.timeout(MACHINE_LEARNING_PING_TIMEOUT), + }); + active = response.ok; + } catch {} + this.setUrlAvailability(url, active); + return active; + } + + private async shouldSkipUrl(url: string) { + const availability = this.urlAvailability[url]; + if (availability === undefined) { + // If this is a new endpoint, then check inline and skip if it fails + if (!(await this.checkAvailability(url))) { + return true; + } + return false; + } + if (!availability.active && Date.now() - availability.lastChecked < MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME) { + // If this is an old inactive endpoint that hasn't been checked in a + // while then check but don't wait for the result, just skip it + // This avoids delays on every search whilst allowing higher priority + // ML servers to recover over time. + void this.checkAvailability(url); + return true; + } + return false; } private async predict(urls: string[], payload: ModelPayload, config: MachineLearningRequest): Promise { const formData = await this.getFormData(payload, config); + let urlCounter = 0; for (const url of urls) { + urlCounter++; + const isLast = urlCounter >= urls.length; + if (!isLast && (await this.shouldSkipUrl(url))) { + continue; + } + try { const response = await fetch(new URL('/predict', url), { method: 'POST', body: formData }); if (response.ok) { + this.setUrlAvailability(url, true); return response.json(); } @@ -37,6 +141,7 @@ export class MachineLearningRepository implements IMachineLearningRepository { `Machine learning request to "${url}" failed: ${error instanceof Error ? error.message : error}`, ); } + this.setUrlAvailability(url, false); } throw new Error(`Machine learning request '${JSON.stringify(config)}' failed for all URLs`); diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts index af24b0c94e..442225f7c8 100644 --- a/server/src/repositories/map.repository.ts +++ b/server/src/repositories/map.repository.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { getName } from 'i18n-iso-countries'; import { Expression, Kysely, sql, SqlBool } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; @@ -8,12 +8,12 @@ import { readFile } from 'node:fs/promises'; import readLine from 'node:readline'; import { citiesFile } from 'src/constants'; import { DB, GeodataPlaces, NaturalearthCountries } from 'src/db'; -import { AssetEntity, withExif } from 'src/entities/asset.entity'; +import { DummyValue, GenerateSql } from 'src/decorators'; import { NaturalEarthCountriesTempEntity } from 'src/entities/natural-earth-countries.entity'; -import { LogLevel, SystemMetadataKey } from 'src/enum'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { SystemMetadataKey } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; +import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; export interface MapMarkerSearchOptions { isArchived?: boolean; @@ -48,7 +48,7 @@ interface MapDB extends DB { export class MapRepository { constructor( private configRepository: ConfigRepository, - @Inject(ISystemMetadataRepository) private metadataRepository: ISystemMetadataRepository, + private metadataRepository: SystemMetadataRepository, private logger: LoggingRepository, @InjectKysely() private db: Kysely, ) { @@ -76,50 +76,47 @@ export class MapRepository { this.logger.log('Geodata import completed'); } - async getMapMarkers( - ownerIds: string[], - albumIds: string[], - options: MapMarkerSearchOptions = {}, - ): Promise { + @GenerateSql({ params: [[DummyValue.UUID], [DummyValue.UUID]] }) + getMapMarkers(ownerIds: string[], albumIds: string[], options: MapMarkerSearchOptions = {}) { const { isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore } = options; - const assets = (await this.db + return this.db .selectFrom('assets') - .$call(withExif) - .select('id') - .leftJoin('albums_assets_assets', (join) => join.onRef('assets.id', '=', 'albums_assets_assets.assetsId')) + .innerJoin('exif', (builder) => + builder + .onRef('assets.id', '=', 'exif.assetId') + .on('exif.latitude', 'is not', null) + .on('exif.longitude', 'is not', null), + ) + .select(['id', 'exif.latitude as lat', 'exif.longitude as lon', 'exif.city', 'exif.state', 'exif.country']) .where('isVisible', '=', true) .$if(isArchived !== undefined, (q) => q.where('isArchived', '=', isArchived!)) .$if(isFavorite !== undefined, (q) => q.where('isFavorite', '=', isFavorite!)) .$if(fileCreatedAfter !== undefined, (q) => q.where('fileCreatedAt', '>=', fileCreatedAfter!)) .$if(fileCreatedBefore !== undefined, (q) => q.where('fileCreatedAt', '<=', fileCreatedBefore!)) .where('deletedAt', 'is', null) - .where('exif.latitude', 'is not', null) - .where('exif.longitude', 'is not', null) .where((eb) => { - const ors: Expression[] = []; + const expression: Expression[] = []; if (ownerIds.length > 0) { - ors.push(eb('ownerId', 'in', ownerIds)); + expression.push(eb('ownerId', 'in', ownerIds)); } if (albumIds.length > 0) { - ors.push(eb('albums_assets_assets.albumsId', 'in', albumIds)); + expression.push( + eb.exists((eb) => + eb + .selectFrom('albums_assets_assets') + .whereRef('assets.id', '=', 'albums_assets_assets.assetsId') + .where('albums_assets_assets.albumsId', 'in', albumIds), + ), + ); } - return eb.or(ors); + return eb.or(expression); }) .orderBy('fileCreatedAt', 'desc') - .execute()) as any as AssetEntity[]; - - return assets.map((asset) => ({ - id: asset.id, - lat: asset.exifInfo!.latitude!, - lon: asset.exifInfo!.longitude!, - city: asset.exifInfo!.city, - state: asset.exifInfo!.state, - country: asset.exifInfo!.country, - })); + .execute() as Promise; } async reverseGeocode(point: GeoPoint): Promise { @@ -140,9 +137,7 @@ export class MapRepository { .executeTakeFirst(); if (response) { - if (this.logger.isLevelEnabled(LogLevel.VERBOSE)) { - this.logger.verbose(`Raw: ${JSON.stringify(response, null, 2)}`); - } + this.logger.verboseFn(() => `Raw: ${JSON.stringify(response, null, 2)}`); const { countryCode, name: city, admin1Name } = response; const country = getName(countryCode, 'en') ?? null; @@ -170,9 +165,8 @@ export class MapRepository { return { country: null, state: null, city: null }; } - if (this.logger.isLevelEnabled(LogLevel.VERBOSE)) { - this.logger.verbose(`Raw: ${JSON.stringify(ne_response, ['id', 'admin', 'admin_a3', 'type'], 2)}`); - } + this.logger.verboseFn(() => `Raw: ${JSON.stringify(ne_response, ['id', 'admin', 'admin_a3', 'type'], 2)}`); + const { admin_a3 } = ne_response; const country = getName(admin_a3, 'en') ?? null; const state = null; diff --git a/server/src/repositories/memory.repository.ts b/server/src/repositories/memory.repository.ts index 042738fe4c..44c7c30857 100644 --- a/server/src/repositories/memory.repository.ts +++ b/server/src/repositories/memory.repository.ts @@ -1,20 +1,53 @@ import { Injectable } from '@nestjs/common'; import { Insertable, Kysely, Updateable } from 'kysely'; import { jsonArrayFrom } from 'kysely/helpers/postgres'; +import { DateTime } from 'luxon'; import { InjectKysely } from 'nestjs-kysely'; import { DB, Memories } from 'src/db'; import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; -import { IBulkAsset } from 'src/utils/asset.util'; +import { MemorySearchDto } from 'src/dtos/memory.dto'; +import { IBulkAsset } from 'src/types'; @Injectable() export class MemoryRepository implements IBulkAsset { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID] }) - search(ownerId: string) { + cleanup() { + return this.db + .deleteFrom('memories') + .where('createdAt', '<', DateTime.now().minus({ days: 30 }).toJSDate()) + .where('isSaved', '=', false) + .execute(); + } + + @GenerateSql( + { params: [DummyValue.UUID, {}] }, + { name: 'date filter', params: [DummyValue.UUID, { for: DummyValue.DATE }] }, + ) + search(ownerId: string, dto: MemorySearchDto) { return this.db .selectFrom('memories') - .selectAll() + .selectAll('memories') + .select((eb) => + jsonArrayFrom( + eb + .selectFrom('assets') + .selectAll('assets') + .innerJoin('memories_assets_assets', 'assets.id', 'memories_assets_assets.assetsId') + .whereRef('memories_assets_assets.memoriesId', '=', 'memories.id') + .orderBy('assets.fileCreatedAt', 'asc') + .where('assets.deletedAt', 'is', null), + ).as('assets'), + ) + .$if(dto.isSaved !== undefined, (qb) => qb.where('isSaved', '=', dto.isSaved!)) + .$if(dto.type !== undefined, (qb) => qb.where('type', '=', dto.type!)) + .$if(dto.for !== undefined, (qb) => + qb + .where((where) => where.or([where('showAt', 'is', null), where('showAt', '<=', dto.for!)])) + .where((where) => where.or([where('hideAt', 'is', null), where('hideAt', '>=', dto.for!)])), + ) + .where('deletedAt', dto.isTrashed ? 'is not' : 'is', null) .where('ownerId', '=', ownerId) .orderBy('memoryAt', 'desc') .execute(); @@ -105,6 +138,7 @@ export class MemoryRepository implements IBulkAsset { .selectAll('assets') .innerJoin('memories_assets_assets', 'assets.id', 'memories_assets_assets.assetsId') .whereRef('memories_assets_assets.memoriesId', '=', 'memories.id') + .orderBy('assets.fileCreatedAt', 'asc') .where('assets.deletedAt', 'is', null), ).as('assets'), ) diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 3f297d709b..bf3a96f21f 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -63,6 +63,14 @@ export interface ImmichTags extends Omit { Name?: string; }[]; }; + + Device?: { + Manufacturer?: string; + ModelName?: string; + }; + + AndroidMake?: string; + AndroidModel?: string; } @Injectable() @@ -73,7 +81,7 @@ export class MetadataRepository { inferTimezoneFromDatestamps: true, inferTimezoneFromTimeStamp: true, useMWG: true, - numericTags: [...DefaultReadTaskOptions.numericTags, 'FocalLength'], + numericTags: [...DefaultReadTaskOptions.numericTags, 'FocalLength', 'FileSize'], /* eslint unicorn/no-array-callback-reference: off, unicorn/no-array-method-this-argument: off */ geoTz: (lat, lon) => geotz.find(lat, lon)[0], // Enable exiftool LFS to parse metadata for files larger than 2GB. @@ -85,6 +93,10 @@ export class MetadataRepository { this.logger.setContext(MetadataRepository.name); } + setMaxConcurrency(concurrency: number) { + this.exiftool.batchCluster.setMaxProcs(concurrency); + } + async teardown() { await this.exiftool.end(); } diff --git a/server/src/repositories/move.repository.ts b/server/src/repositories/move.repository.ts index c0177f3f30..706e23cef7 100644 --- a/server/src/repositories/move.repository.ts +++ b/server/src/repositories/move.repository.ts @@ -1,14 +1,15 @@ import { Injectable } from '@nestjs/common'; -import { Insertable, Kysely, Updateable } from 'kysely'; +import { Insertable, Kysely, sql, Updateable } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { DB, MoveHistory } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { MoveEntity } from 'src/entities/move.entity'; -import { PathType } from 'src/enum'; -import { IMoveRepository } from 'src/interfaces/move.interface'; +import { AssetPathType, PathType } from 'src/enum'; + +export type MoveCreate = Pick & Partial; @Injectable() -export class MoveRepository implements IMoveRepository { +export class MoveRepository { constructor(@InjectKysely() private db: Kysely) {} create(entity: Insertable): Promise { @@ -46,4 +47,28 @@ export class MoveRepository implements IMoveRepository { .returningAll() .executeTakeFirstOrThrow() as unknown as Promise; } + + @GenerateSql() + async cleanMoveHistory(): Promise { + await this.db + .deleteFrom('move_history') + .where((eb) => + eb( + 'move_history.entityId', + 'not in', + eb.selectFrom('assets').select('id').whereRef('assets.id', '=', 'move_history.entityId'), + ), + ) + .where('move_history.pathType', '=', sql.lit(AssetPathType.ORIGINAL)) + .execute(); + } + + @GenerateSql() + async cleanMoveHistorySingle(assetId: string): Promise { + await this.db + .deleteFrom('move_history') + .where('move_history.pathType', '=', sql.lit(AssetPathType.ORIGINAL)) + .where('entityId', '=', assetId) + .execute(); + } } diff --git a/server/src/repositories/notification.repository.spec.ts b/server/src/repositories/notification.repository.spec.ts index 0d8e826c66..6d17501d03 100644 --- a/server/src/repositories/notification.repository.spec.ts +++ b/server/src/repositories/notification.repository.spec.ts @@ -1,17 +1,12 @@ import { LoggingRepository } from 'src/repositories/logging.repository'; import { EmailRenderRequest, EmailTemplate, NotificationRepository } from 'src/repositories/notification.repository'; -import { ILoggingRepository } from 'src/types'; -import { newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { Mocked } from 'vitest'; +import { automock } from 'test/utils'; describe(NotificationRepository.name, () => { let sut: NotificationRepository; - let loggerMock: Mocked; beforeEach(() => { - loggerMock = newLoggingRepositoryMock(); - - sut = new NotificationRepository(loggerMock as ILoggingRepository as LoggingRepository); + sut = new NotificationRepository(automock(LoggingRepository, { args: [, { getEnv: () => ({}) }], strict: false })); }); describe('renderEmail', () => { diff --git a/server/src/repositories/notification.repository.ts b/server/src/repositories/notification.repository.ts index fdb74cfdb2..91f03b928b 100644 --- a/server/src/repositories/notification.repository.ts +++ b/server/src/repositories/notification.repository.ts @@ -7,12 +7,7 @@ import { AlbumUpdateEmail } from 'src/emails/album-update.email'; import { TestEmail } from 'src/emails/test.email'; import { WelcomeEmail } from 'src/emails/welcome.email'; import { LoggingRepository } from 'src/repositories/logging.repository'; - -export type EmailImageAttachment = { - filename: string; - path: string; - cid: string; -}; +import { EmailImageAttachment } from 'src/types'; export type SendEmailOptions = { from: string; diff --git a/server/src/repositories/oauth.repository.ts b/server/src/repositories/oauth.repository.ts index 85263cd647..29e6ffbb52 100644 --- a/server/src/repositories/oauth.repository.ts +++ b/server/src/repositories/oauth.repository.ts @@ -43,7 +43,12 @@ export class OAuthRepository { const params = client.callbackParams(url); try { const tokens = await client.callback(redirectUrl, params, { state: params.state }); - return await client.userinfo(tokens.access_token || ''); + const profile = await client.userinfo(tokens.access_token || ''); + if (!profile.sub) { + throw new Error('Unexpected profile response, no `sub`'); + } + + return profile; } catch (error: Error | any) { if (error.message.includes('unexpected JWT alg received')) { this.logger.warn( diff --git a/server/src/repositories/partner.repository.ts b/server/src/repositories/partner.repository.ts index 929c06a1f5..f799ff56f2 100644 --- a/server/src/repositories/partner.repository.ts +++ b/server/src/repositories/partner.repository.ts @@ -5,7 +5,16 @@ import { InjectKysely } from 'nestjs-kysely'; import { DB, Partners, Users } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { PartnerEntity } from 'src/entities/partner.entity'; -import { IPartnerRepository, PartnerIds } from 'src/interfaces/partner.interface'; + +export interface PartnerIds { + sharedById: string; + sharedWithId: string; +} + +export enum PartnerDirection { + SharedBy = 'shared-by', + SharedWith = 'shared-with', +} const columns = ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'] as const; @@ -28,7 +37,7 @@ const withSharedWith = (eb: ExpressionBuilder) => { }; @Injectable() -export class PartnerRepository implements IPartnerRepository { +export class PartnerRepository { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID] }) diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 45183f39d6..d5855d3b91 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, sql } from 'kysely'; +import { ExpressionBuilder, Insertable, Kysely, Selectable, sql } from 'kysely'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; import { AssetFaces, DB, FaceSearch, Person } from 'src/db'; @@ -7,23 +7,53 @@ import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { PersonEntity } from 'src/entities/person.entity'; import { SourceType } from 'src/enum'; -import { - AssetFaceId, - DeleteFacesOptions, - IPersonRepository, - PeopleStatistics, - PersonNameResponse, - PersonNameSearchOptions, - PersonSearchOptions, - PersonStatistics, - SelectFaceOptions, - UnassignFacesOptions, - UpdateFacesData, -} from 'src/interfaces/person.interface'; -import { mapUpsertColumns } from 'src/utils/database'; +import { removeUndefinedKeys } from 'src/utils/database'; import { Paginated, PaginationOptions } from 'src/utils/pagination'; import { FindOptionsRelations } from 'typeorm'; +export interface PersonSearchOptions { + minimumFaceCount: number; + withHidden: boolean; + closestFaceAssetId?: string; +} + +export interface PersonNameSearchOptions { + withHidden?: boolean; +} + +export interface PersonNameResponse { + id: string; + name: string; +} + +export interface AssetFaceId { + assetId: string; + personId: string; +} + +export interface UpdateFacesData { + oldPersonId?: string; + faceIds?: string[]; + newPersonId: string; +} + +export interface PersonStatistics { + assets: number; +} + +export interface PeopleStatistics { + total: number; + hidden: number; +} + +export interface DeleteFacesOptions { + sourceType: SourceType; +} + +export type UnassignFacesOptions = DeleteFacesOptions; + +export type SelectFaceOptions = (keyof Selectable)[]; + const withPerson = (eb: ExpressionBuilder) => { return jsonObjectFrom( eb.selectFrom('person').selectAll('person').whereRef('person.id', '=', 'asset_faces.personId'), @@ -43,7 +73,7 @@ const withFaceSearch = (eb: ExpressionBuilder) => { }; @Injectable() -export class PersonRepository implements IPersonRepository { +export class PersonRepository { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] }) @@ -100,7 +130,7 @@ export class PersonRepository implements IPersonRepository { .$if(!!options.personId, (qb) => qb.where('asset_faces.personId', '=', options.personId!)) .$if(!!options.sourceType, (qb) => qb.where('asset_faces.sourceType', '=', options.sourceType!)) .$if(!!options.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!)) - .$if(!!options.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!)) + .where('asset_faces.deletedAt', 'is', null) .stream() as AsyncIterableIterator; } @@ -109,7 +139,7 @@ export class PersonRepository implements IPersonRepository { .selectFrom('person') .selectAll('person') .$if(!!options.ownerId, (qb) => qb.where('person.ownerId', '=', options.ownerId!)) - .$if(!!options.thumbnailPath, (qb) => qb.where('person.thumbnailPath', '=', options.thumbnailPath!)) + .$if(options.thumbnailPath !== undefined, (qb) => qb.where('person.thumbnailPath', '=', options.thumbnailPath!)) .$if(options.faceAssetId === null, (qb) => qb.where('person.faceAssetId', 'is', null)) .$if(!!options.faceAssetId, (qb) => qb.where('person.faceAssetId', '=', options.faceAssetId!)) .$if(options.isHidden !== undefined, (qb) => qb.where('person.isHidden', '=', options.isHidden!)) @@ -132,7 +162,9 @@ export class PersonRepository implements IPersonRepository { .on('assets.deletedAt', 'is', null), ) .where('person.ownerId', '=', userId) + .where('asset_faces.deletedAt', 'is', null) .orderBy('person.isHidden', 'asc') + .orderBy('person.isFavorite', 'desc') .having((eb) => eb.or([ eb('person.name', '!=', ''), @@ -182,6 +214,7 @@ export class PersonRepository implements IPersonRepository { .selectFrom('person') .selectAll('person') .leftJoin('asset_faces', 'asset_faces.personId', 'person.id') + .where('asset_faces.deletedAt', 'is', null) .having((eb) => eb.fn.count('asset_faces.assetId'), '=', 0) .groupBy('person.id') .execute() as Promise; @@ -194,6 +227,7 @@ export class PersonRepository implements IPersonRepository { .selectAll('asset_faces') .select(withPerson) .where('asset_faces.assetId', '=', assetId) + .where('asset_faces.deletedAt', 'is', null) .orderBy('asset_faces.boundingBoxX1', 'asc') .execute() as Promise; } @@ -206,6 +240,7 @@ export class PersonRepository implements IPersonRepository { .selectAll('asset_faces') .select(withPerson) .where('asset_faces.id', '=', id) + .where('asset_faces.deletedAt', 'is', null) .executeTakeFirstOrThrow() as Promise; } @@ -223,6 +258,7 @@ export class PersonRepository implements IPersonRepository { .select(withAsset) .$if(!!relations?.faceSearch, (qb) => qb.select(withFaceSearch)) .where('asset_faces.id', '=', id) + .where('asset_faces.deletedAt', 'is', null) .executeTakeFirst() as Promise; } @@ -284,10 +320,10 @@ export class PersonRepository implements IPersonRepository { .onRef('assets.id', '=', 'asset_faces.assetId') .on('asset_faces.personId', '=', personId) .on('assets.isArchived', '=', false) - .on('assets.deletedAt', 'is', null) - .on('assets.livePhotoVideoId', 'is', null), + .on('assets.deletedAt', 'is', null), ) .select((eb) => eb.fn.count(eb.fn('distinct', ['assets.id'])).as('count')) + .where('asset_faces.deletedAt', 'is', null) .executeTakeFirst(); return { @@ -301,6 +337,7 @@ export class PersonRepository implements IPersonRepository { .selectFrom('person') .innerJoin('asset_faces', 'asset_faces.personId', 'person.id') .where('person.ownerId', '=', userId) + .where('asset_faces.deletedAt', 'is', null) .innerJoin('assets', (join) => join .onRef('assets.id', '=', 'asset_faces.assetId') @@ -380,7 +417,22 @@ export class PersonRepository implements IPersonRepository { await this.db .insertInto('person') .values(people) - .onConflict((oc) => oc.column('id').doUpdateSet(() => mapUpsertColumns('person', people[0], ['id']))) + .onConflict((oc) => + oc.column('id').doUpdateSet((eb) => + removeUndefinedKeys( + { + name: eb.ref('excluded.name'), + birthDate: eb.ref('excluded.birthDate'), + thumbnailPath: eb.ref('excluded.thumbnailPath'), + faceAssetId: eb.ref('excluded.faceAssetId'), + isHidden: eb.ref('excluded.isHidden'), + isFavorite: eb.ref('excluded.isFavorite'), + color: eb.ref('excluded.color'), + }, + people[0], + ), + ), + ) .execute(); } @@ -405,6 +457,7 @@ export class PersonRepository implements IPersonRepository { .select(withPerson) .where('asset_faces.assetId', 'in', assetIds) .where('asset_faces.personId', 'in', personIds) + .where('asset_faces.deletedAt', 'is', null) .execute() as Promise; } @@ -414,6 +467,7 @@ export class PersonRepository implements IPersonRepository { .selectFrom('asset_faces') .selectAll('asset_faces') .where('asset_faces.personId', '=', personId) + .where('asset_faces.deletedAt', 'is', null) .executeTakeFirst() as Promise; } @@ -427,6 +481,20 @@ export class PersonRepository implements IPersonRepository { return result?.latestDate; } + async createAssetFace(face: Insertable): Promise { + await this.db.insertInto('asset_faces').values(face).execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + async deleteAssetFace(id: string): Promise { + await this.db.deleteFrom('asset_faces').where('asset_faces.id', '=', id).execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + async softDeleteAssetFaces(id: string): Promise { + await this.db.updateTable('asset_faces').set({ deletedAt: new Date() }).where('asset_faces.id', '=', id).execute(); + } + private async vacuum({ reindexVectors }: { reindexVectors: boolean }): Promise { await sql`VACUUM ANALYZE asset_faces, face_search, person`.execute(this.db); await sql`REINDEX TABLE asset_faces`.execute(this.db); diff --git a/server/src/repositories/process.repository.ts b/server/src/repositories/process.repository.ts index bd533129f9..d065554b0c 100644 --- a/server/src/repositories/process.repository.ts +++ b/server/src/repositories/process.repository.ts @@ -1,13 +1,11 @@ import { Injectable } from '@nestjs/common'; import { ChildProcessWithoutNullStreams, spawn, SpawnOptionsWithoutStdio } from 'node:child_process'; -import { IProcessRepository } from 'src/interfaces/process.interface'; import { LoggingRepository } from 'src/repositories/logging.repository'; -import { StorageRepository } from 'src/repositories/storage.repository'; @Injectable() -export class ProcessRepository implements IProcessRepository { +export class ProcessRepository { constructor(private logger: LoggingRepository) { - this.logger.setContext(StorageRepository.name); + this.logger.setContext(ProcessRepository.name); } spawn(command: string, args: readonly string[], options?: SpawnOptionsWithoutStdio): ChildProcessWithoutNullStreams { diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index fb59157c80..e2e389f47c 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -6,26 +6,203 @@ import { DB } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { AssetEntity, searchAssetBuilder } from 'src/entities/asset.entity'; import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity'; -import { AssetType } from 'src/enum'; -import { - AssetDuplicateSearch, - AssetSearchOptions, - FaceEmbeddingSearch, - GetCameraMakesOptions, - GetCameraModelsOptions, - GetCitiesOptions, - GetStatesOptions, - ISearchRepository, - SearchPaginationOptions, - SmartSearchOptions, -} from 'src/interfaces/search.interface'; +import { AssetStatus, AssetType } from 'src/enum'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { anyUuid, asUuid } from 'src/utils/database'; import { Paginated } from 'src/utils/pagination'; import { isValidInteger } from 'src/validation'; +export interface SearchResult { + /** total matches */ + total: number; + /** collection size */ + count: number; + /** current page */ + page: number; + /** items for page */ + items: T[]; + /** score */ + distances: number[]; + facets: SearchFacet[]; +} + +export interface SearchFacet { + fieldName: string; + counts: Array<{ + count: number; + value: string; + }>; +} + +export type SearchExploreItemSet = Array<{ + value: string; + data: T; +}>; + +export interface SearchExploreItem { + fieldName: string; + items: SearchExploreItemSet; +} + +export interface SearchAssetIDOptions { + checksum?: Buffer; + deviceAssetId?: string; + id?: string; +} + +export interface SearchUserIdOptions { + deviceId?: string; + libraryId?: string | null; + userIds?: string[]; +} + +export type SearchIdOptions = SearchAssetIDOptions & SearchUserIdOptions; + +export interface SearchStatusOptions { + isArchived?: boolean; + isEncoded?: boolean; + isFavorite?: boolean; + isMotion?: boolean; + isOffline?: boolean; + isVisible?: boolean; + isNotInAlbum?: boolean; + type?: AssetType; + status?: AssetStatus; + withArchived?: boolean; + withDeleted?: boolean; +} + +export interface SearchOneToOneRelationOptions { + withExif?: boolean; + withStacked?: boolean; +} + +export interface SearchRelationOptions extends SearchOneToOneRelationOptions { + withFaces?: boolean; + withPeople?: boolean; +} + +export interface SearchDateOptions { + createdBefore?: Date; + createdAfter?: Date; + takenBefore?: Date; + takenAfter?: Date; + trashedBefore?: Date; + trashedAfter?: Date; + updatedBefore?: Date; + updatedAfter?: Date; +} + +export interface SearchPathOptions { + encodedVideoPath?: string; + originalFileName?: string; + originalPath?: string; + previewPath?: string; + thumbnailPath?: string; +} + +export interface SearchExifOptions { + city?: string | null; + country?: string | null; + lensModel?: string | null; + make?: string | null; + model?: string | null; + state?: string | null; + description?: string | null; + rating?: number | null; +} + +export interface SearchEmbeddingOptions { + embedding: string; + userIds: string[]; +} + +export interface SearchPeopleOptions { + personIds?: string[]; +} + +export interface SearchTagOptions { + tagIds?: string[]; +} + +export interface SearchOrderOptions { + orderDirection?: 'asc' | 'desc'; +} + +export interface SearchPaginationOptions { + page: number; + size: number; +} + +type BaseAssetSearchOptions = SearchDateOptions & + SearchIdOptions & + SearchExifOptions & + SearchOrderOptions & + SearchPathOptions & + SearchStatusOptions & + SearchUserIdOptions & + SearchPeopleOptions & + SearchTagOptions; + +export type AssetSearchOptions = BaseAssetSearchOptions & SearchRelationOptions; + +export type AssetSearchOneToOneRelationOptions = BaseAssetSearchOptions & SearchOneToOneRelationOptions; + +export type AssetSearchBuilderOptions = Omit; + +export type SmartSearchOptions = SearchDateOptions & + SearchEmbeddingOptions & + SearchExifOptions & + SearchOneToOneRelationOptions & + SearchStatusOptions & + SearchUserIdOptions & + SearchPeopleOptions & + SearchTagOptions; + +export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { + hasPerson?: boolean; + numResults: number; + maxDistance: number; +} + +export interface AssetDuplicateSearch { + assetId: string; + embedding: string; + maxDistance: number; + type: AssetType; + userIds: string[]; +} + +export interface FaceSearchResult { + distance: number; + id: string; + personId: string | null; +} + +export interface AssetDuplicateResult { + assetId: string; + duplicateId: string | null; + distance: number; +} + +export interface GetStatesOptions { + country?: string; +} + +export interface GetCitiesOptions extends GetStatesOptions { + state?: string; +} + +export interface GetCameraModelsOptions { + make?: string; +} + +export interface GetCameraMakesOptions { + model?: string; +} + @Injectable() -export class SearchRepository implements ISearchRepository { +export class SearchRepository { constructor( private logger: LoggingRepository, @InjectKysely() private db: Kysely, @@ -72,8 +249,14 @@ export class SearchRepository implements ISearchRepository { async searchRandom(size: number, options: AssetSearchOptions): Promise { const uuid = randomUUID(); const builder = searchAssetBuilder(this.db, options); - const lessThan = builder.where('assets.id', '<', uuid).orderBy('assets.id').limit(size); - const greaterThan = builder.where('assets.id', '>', uuid).orderBy('assets.id').limit(size); + const lessThan = builder + .where('assets.id', '<', uuid) + .orderBy(sql`random()`) + .limit(size); + const greaterThan = builder + .where('assets.id', '>', uuid) + .orderBy(sql`random()`) + .limit(size); const { rows } = await sql`${lessThan} union all ${greaterThan} limit ${size}`.execute(this.db); return rows as any as AssetEntity[]; } @@ -135,6 +318,7 @@ export class SearchRepository implements ISearchRepository { .where('assets.isVisible', '=', true) .where('assets.type', '=', type) .where('assets.id', '!=', asUuid(assetId)) + .where('assets.stackId', 'is', null) .orderBy(sql`smart_search.embedding <=> ${embedding}`) .limit(64), ) @@ -220,7 +404,7 @@ export class SearchRepository implements ISearchRepository { .where('assets.ownerId', '=', anyUuid(userIds)) .where('assets.isVisible', '=', true) .where('assets.isArchived', '=', false) - .where('assets.type', '=', 'IMAGE') + .where('assets.type', '=', AssetType.IMAGE) .where('assets.deletedAt', 'is', null) .orderBy('city') .limit(1); @@ -237,7 +421,7 @@ export class SearchRepository implements ISearchRepository { .where('assets.ownerId', '=', anyUuid(userIds)) .where('assets.isVisible', '=', true) .where('assets.isArchived', '=', false) - .where('assets.type', '=', 'IMAGE') + .where('assets.type', '=', AssetType.IMAGE) .where('assets.deletedAt', 'is', null) .whereRef('exif.city', '>', 'cte.city') .orderBy('city') @@ -292,7 +476,7 @@ export class SearchRepository implements ISearchRepository { await sql`truncate ${sql.table('smart_search')}`.execute(trx); await trx.schema .alterTable('smart_search') - .alterColumn('embedding', (col) => col.setDataType(sql.lit(`vector(${dimSize})`))) + .alterColumn('embedding', (col) => col.setDataType(sql.raw(`vector(${dimSize})`))) .execute(); await sql`reindex index clip_index`.execute(trx); }); diff --git a/server/src/repositories/session.repository.ts b/server/src/repositories/session.repository.ts index 3e6c897721..85ea5f890e 100644 --- a/server/src/repositories/session.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -1,38 +1,48 @@ import { Injectable } from '@nestjs/common'; import { Insertable, Kysely, Updateable } from 'kysely'; +import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; +import { columns } from 'src/database'; import { DB, Sessions } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { SessionEntity, withUser } from 'src/entities/session.entity'; -import { ISessionRepository, SessionSearchOptions } from 'src/interfaces/session.interface'; +import { withUser } from 'src/entities/session.entity'; import { asUuid } from 'src/utils/database'; +export type SessionSearchOptions = { updatedBefore: Date }; + @Injectable() -export class SessionRepository implements ISessionRepository { +export class SessionRepository { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [{ updatedBefore: DummyValue.DATE }] }) - search(options: SessionSearchOptions): Promise { + search(options: SessionSearchOptions) { return this.db .selectFrom('sessions') .selectAll() .where('sessions.updatedAt', '<=', options.updatedBefore) - .execute() as Promise; + .execute(); } @GenerateSql({ params: [DummyValue.STRING] }) - getByToken(token: string): Promise { + getByToken(token: string) { return this.db .selectFrom('sessions') - .innerJoinLateral(withUser, (join) => join.onTrue()) - .selectAll('sessions') - .select((eb) => eb.fn.toJson('user').as('user')) + .select((eb) => [ + ...columns.authSession, + jsonObjectFrom( + eb + .selectFrom('users') + .select(columns.authUser) + .whereRef('users.id', '=', 'sessions.userId') + .where('users.deletedAt', 'is', null), + ).as('user'), + ]) .where('sessions.token', '=', token) - .executeTakeFirst() as Promise; + .executeTakeFirst(); } @GenerateSql({ params: [DummyValue.UUID] }) - getByUserId(userId: string): Promise { + getByUserId(userId: string) { return this.db .selectFrom('sessions') .innerJoinLateral(withUser, (join) => join.onTrue()) @@ -41,30 +51,24 @@ export class SessionRepository implements ISessionRepository { .where('sessions.userId', '=', userId) .orderBy('sessions.updatedAt', 'desc') .orderBy('sessions.createdAt', 'desc') - .execute() as unknown as Promise; + .execute(); } - async create(dto: Insertable): Promise { - const { id, token, userId, createdAt, updatedAt, deviceType, deviceOS } = await this.db - .insertInto('sessions') - .values(dto) - .returningAll() - .executeTakeFirstOrThrow(); - - return { id, token, userId, createdAt, updatedAt, deviceType, deviceOS } as SessionEntity; + create(dto: Insertable) { + return this.db.insertInto('sessions').values(dto).returningAll().executeTakeFirstOrThrow(); } - update(id: string, dto: Updateable): Promise { + update(id: string, dto: Updateable) { return this.db .updateTable('sessions') .set(dto) .where('sessions.id', '=', asUuid(id)) .returningAll() - .executeTakeFirstOrThrow() as Promise; + .executeTakeFirstOrThrow(); } @GenerateSql({ params: [DummyValue.UUID] }) - async delete(id: string): Promise { + async delete(id: string) { await this.db.deleteFrom('sessions').where('id', '=', asUuid(id)).execute(); } } diff --git a/server/src/repositories/shared-link.repository.ts b/server/src/repositories/shared-link.repository.ts index 6473100387..52b5b7a2fe 100644 --- a/server/src/repositories/shared-link.repository.ts +++ b/server/src/repositories/shared-link.repository.ts @@ -3,14 +3,19 @@ import { Insertable, Kysely, sql, Updateable } from 'kysely'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; import _ from 'lodash'; import { InjectKysely } from 'nestjs-kysely'; +import { columns } from 'src/database'; import { DB, SharedLinks } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SharedLinkType } from 'src/enum'; -import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; + +export type SharedLinkSearchOptions = { + userId: string; + albumId?: string; +}; @Injectable() -export class SharedLinkRepository implements ISharedLinkRepository { +export class SharedLinkRepository { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) @@ -92,8 +97,8 @@ export class SharedLinkRepository implements ISharedLinkRepository { .executeTakeFirst() as Promise; } - @GenerateSql({ params: [DummyValue.UUID] }) - getAll(userId: string): Promise { + @GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }] }) + getAll({ userId, albumId }: SharedLinkSearchOptions): Promise { return this.db .selectFrom('shared_links') .selectAll('shared_links') @@ -103,12 +108,13 @@ export class SharedLinkRepository implements ISharedLinkRepository { (eb) => eb .selectFrom('assets') + .select((eb) => eb.fn.jsonAgg('assets').as('assets')) .whereRef('assets.id', '=', 'shared_link__asset.assetsId') .where('assets.deletedAt', 'is', null) - .selectAll('assets') .as('assets'), (join) => join.onTrue(), ) + .select((eb) => eb.fn.toJson('assets').as('assets')) .leftJoinLateral( (eb) => eb @@ -148,45 +154,27 @@ export class SharedLinkRepository implements ISharedLinkRepository { ) .select((eb) => eb.fn.toJson('album').as('album')) .where((eb) => eb.or([eb('shared_links.type', '=', SharedLinkType.INDIVIDUAL), eb('album.id', 'is not', null)])) + .$if(!!albumId, (eb) => eb.where('shared_links.albumId', '=', albumId!)) .orderBy('shared_links.createdAt', 'desc') .distinctOn(['shared_links.createdAt']) .execute() as unknown as Promise; } @GenerateSql({ params: [DummyValue.BUFFER] }) - async getByKey(key: Buffer): Promise { + async getByKey(key: Buffer) { return this.db .selectFrom('shared_links') - .selectAll('shared_links') .where('shared_links.key', '=', key) .leftJoin('albums', 'albums.id', 'shared_links.albumId') .where('albums.deletedAt', 'is', null) - .select((eb) => + .select((eb) => [ + ...columns.authSharedLink, jsonObjectFrom( - eb - .selectFrom('users') - .select([ - 'users.id', - 'users.email', - 'users.createdAt', - 'users.profileImagePath', - 'users.isAdmin', - 'users.shouldChangePassword', - 'users.deletedAt', - 'users.oauthId', - 'users.updatedAt', - 'users.storageLabel', - 'users.name', - 'users.quotaSizeInBytes', - 'users.quotaUsageInBytes', - 'users.status', - 'users.profileChangedAt', - ]) - .whereRef('users.id', '=', 'shared_links.userId'), + eb.selectFrom('users').select(columns.authUser).whereRef('users.id', '=', 'shared_links.userId'), ).as('user'), - ) + ]) .where((eb) => eb.or([eb('shared_links.type', '=', SharedLinkType.INDIVIDUAL), eb('albums.id', 'is not', null)])) - .executeTakeFirst() as Promise; + .executeTakeFirst(); } async create(entity: Insertable & { assetIds?: string[] }): Promise { diff --git a/server/src/repositories/stack.repository.ts b/server/src/repositories/stack.repository.ts index 018d7e77a4..ae96005350 100644 --- a/server/src/repositories/stack.repository.ts +++ b/server/src/repositories/stack.repository.ts @@ -5,9 +5,13 @@ import { InjectKysely } from 'nestjs-kysely'; import { DB } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { StackEntity } from 'src/entities/stack.entity'; -import { IStackRepository, StackSearch } from 'src/interfaces/stack.interface'; import { asUuid } from 'src/utils/database'; +export interface StackSearch { + ownerId: string; + primaryAssetId?: string; +} + const withAssets = (eb: ExpressionBuilder, withTags = false) => { return jsonArrayFrom( eb @@ -35,7 +39,7 @@ const withAssets = (eb: ExpressionBuilder, withTags = false) }; @Injectable() -export class StackRepository implements IStackRepository { +export class StackRepository { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [{ ownerId: DummyValue.UUID }] }) diff --git a/server/src/repositories/storage.repository.spec.ts b/server/src/repositories/storage.repository.spec.ts index 4c4a9d50b6..85ff4a746f 100644 --- a/server/src/repositories/storage.repository.spec.ts +++ b/server/src/repositories/storage.repository.spec.ts @@ -2,8 +2,7 @@ import mockfs from 'mock-fs'; import { CrawlOptionsDto } from 'src/dtos/library.dto'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; -import { ILoggingRepository } from 'src/types'; -import { newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { automock } from 'test/utils'; interface Test { test: string; @@ -182,11 +181,9 @@ const tests: Test[] = [ describe(StorageRepository.name, () => { let sut: StorageRepository; - let logger: ILoggingRepository; beforeEach(() => { - logger = newLoggingRepositoryMock(); - sut = new StorageRepository(logger as LoggingRepository); + sut = new StorageRepository(automock(LoggingRepository, { args: [, { getEnv: () => ({}) }], strict: false })); }); afterEach(() => { diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts index 6766f442b8..15b81e7106 100644 --- a/server/src/repositories/storage.repository.ts +++ b/server/src/repositories/storage.repository.ts @@ -5,20 +5,38 @@ import { escapePath, glob, globStream } from 'fast-glob'; import { constants, createReadStream, createWriteStream, existsSync, mkdirSync } from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { Writable } from 'node:stream'; +import { Readable, Writable } from 'node:stream'; import { CrawlOptionsDto, WalkOptionsDto } from 'src/dtos/library.dto'; -import { - DiskUsage, - IStorageRepository, - ImmichReadStream, - ImmichZipStream, - WatchEvents, -} from 'src/interfaces/storage.interface'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { mimeTypes } from 'src/utils/mime-types'; +export interface WatchEvents { + onReady(): void; + onAdd(path: string): void; + onChange(path: string): void; + onUnlink(path: string): void; + onError(error: Error): void; +} + +export interface ImmichReadStream { + stream: Readable; + type?: string; + length?: number; +} + +export interface ImmichZipStream extends ImmichReadStream { + addFile: (inputPath: string, filename: string) => void; + finalize: () => Promise; +} + +export interface DiskUsage { + available: number; + free: number; + total: number; +} + @Injectable() -export class StorageRepository implements IStorageRepository { +export class StorageRepository { constructor(private logger: LoggingRepository) { this.logger.setContext(StorageRepository.name); } diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts new file mode 100644 index 0000000000..bc3205c0a3 --- /dev/null +++ b/server/src/repositories/sync.repository.ts @@ -0,0 +1,162 @@ +import { Injectable } from '@nestjs/common'; +import { Insertable, Kysely, SelectQueryBuilder, sql } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; +import { columns } from 'src/database'; +import { DB, SessionSyncCheckpoints } from 'src/db'; +import { SyncEntityType } from 'src/enum'; +import { SyncAck } from 'src/types'; + +type auditTables = 'users_audit' | 'partners_audit' | 'assets_audit'; +type upsertTables = 'users' | 'partners' | 'assets' | 'exif'; + +@Injectable() +export class SyncRepository { + constructor(@InjectKysely() private db: Kysely) {} + + getCheckpoints(sessionId: string) { + return this.db + .selectFrom('session_sync_checkpoints') + .select(['type', 'ack']) + .where('sessionId', '=', sessionId) + .execute(); + } + + upsertCheckpoints(items: Insertable[]) { + return this.db + .insertInto('session_sync_checkpoints') + .values(items) + .onConflict((oc) => + oc.columns(['sessionId', 'type']).doUpdateSet((eb) => ({ + ack: eb.ref('excluded.ack'), + })), + ) + .execute(); + } + + deleteCheckpoints(sessionId: string, types?: SyncEntityType[]) { + return this.db + .deleteFrom('session_sync_checkpoints') + .where('sessionId', '=', sessionId) + .$if(!!types, (qb) => qb.where('type', 'in', types!)) + .execute(); + } + + getUserUpserts(ack?: SyncAck) { + return this.db + .selectFrom('users') + .select(['id', 'name', 'email', 'deletedAt', 'updateId']) + .$call((qb) => this.upsertTableFilters(qb, ack)) + .stream(); + } + + getUserDeletes(ack?: SyncAck) { + return this.db + .selectFrom('users_audit') + .select(['id', 'userId']) + .$call((qb) => this.auditTableFilters(qb, ack)) + .stream(); + } + + getPartnerUpserts(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('partners') + .select(['sharedById', 'sharedWithId', 'inTimeline', 'updateId']) + .where((eb) => eb.or([eb('sharedById', '=', userId), eb('sharedWithId', '=', userId)])) + .$call((qb) => this.upsertTableFilters(qb, ack)) + .stream(); + } + + getPartnerDeletes(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('partners_audit') + .select(['id', 'sharedById', 'sharedWithId']) + .where((eb) => eb.or([eb('sharedById', '=', userId), eb('sharedWithId', '=', userId)])) + .$call((qb) => this.auditTableFilters(qb, ack)) + .stream(); + } + + getAssetUpserts(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('assets') + .select(columns.syncAsset) + .where('ownerId', '=', userId) + .$call((qb) => this.upsertTableFilters(qb, ack)) + .stream(); + } + + getPartnerAssetsUpserts(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('assets') + .select(columns.syncAsset) + .where('ownerId', 'in', (eb) => + eb.selectFrom('partners').select(['sharedById']).where('sharedWithId', '=', userId), + ) + .$call((qb) => this.upsertTableFilters(qb, ack)) + .stream(); + } + + getAssetDeletes(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('assets_audit') + .select(['id', 'assetId']) + .where('ownerId', '=', userId) + .$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId)) + .$call((qb) => this.auditTableFilters(qb, ack)) + .stream(); + } + + getPartnerAssetDeletes(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('assets_audit') + .select(['id', 'assetId']) + .where('ownerId', 'in', (eb) => + eb.selectFrom('partners').select(['sharedById']).where('sharedWithId', '=', userId), + ) + .$call((qb) => this.auditTableFilters(qb, ack)) + .stream(); + } + + getAssetExifsUpserts(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('exif') + .select(columns.syncAssetExif) + .where('assetId', 'in', (eb) => eb.selectFrom('assets').select('id').where('ownerId', '=', userId)) + .$call((qb) => this.upsertTableFilters(qb, ack)) + .stream(); + } + + getPartnerAssetExifsUpserts(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('exif') + .select(columns.syncAssetExif) + .where('assetId', 'in', (eb) => + eb + .selectFrom('assets') + .select('id') + .where('ownerId', 'in', (eb) => + eb.selectFrom('partners').select(['sharedById']).where('sharedWithId', '=', userId), + ), + ) + .$call((qb) => this.upsertTableFilters(qb, ack)) + .stream(); + } + + private auditTableFilters, D>(qb: SelectQueryBuilder, ack?: SyncAck) { + const builder = qb as SelectQueryBuilder; + return builder + .where('deletedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId)) + .orderBy(['id asc']) as SelectQueryBuilder; + } + + private upsertTableFilters, D>( + qb: SelectQueryBuilder, + ack?: SyncAck, + ) { + const builder = qb as SelectQueryBuilder; + return builder + .where('updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .$if(!!ack, (qb) => qb.where('updateId', '>', ack!.updateId)) + .orderBy(['updateId asc']) as SelectQueryBuilder; + } +} diff --git a/server/src/repositories/system-metadata.repository.ts b/server/src/repositories/system-metadata.repository.ts index 7cd4d715e2..a110b9bc44 100644 --- a/server/src/repositories/system-metadata.repository.ts +++ b/server/src/repositories/system-metadata.repository.ts @@ -5,12 +5,11 @@ import { readFile } from 'node:fs/promises'; import { DB, SystemMetadata as DbSystemMetadata } from 'src/db'; import { GenerateSql } from 'src/decorators'; import { SystemMetadata } from 'src/entities/system-metadata.entity'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; type Upsert = Insertable; @Injectable() -export class SystemMetadataRepository implements ISystemMetadataRepository { +export class SystemMetadataRepository { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: ['metadata_key'] }) diff --git a/server/src/repositories/tag.repository.ts b/server/src/repositories/tag.repository.ts index 3489f43640..c0ca6ebf37 100644 --- a/server/src/repositories/tag.repository.ts +++ b/server/src/repositories/tag.repository.ts @@ -1,208 +1,188 @@ import { Injectable } from '@nestjs/common'; -import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { Insertable, Kysely, sql, Updateable } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; +import { columns } from 'src/database'; +import { DB, TagAsset, Tags } from 'src/db'; import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; -import { TagEntity } from 'src/entities/tag.entity'; -import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface'; import { LoggingRepository } from 'src/repositories/logging.repository'; -import { DataSource, In, Repository } from 'typeorm'; @Injectable() -export class TagRepository implements ITagRepository { +export class TagRepository { constructor( - @InjectDataSource() private dataSource: DataSource, - @InjectRepository(TagEntity) private repository: Repository, + @InjectKysely() private db: Kysely, private logger: LoggingRepository, ) { this.logger.setContext(TagRepository.name); } - get(id: string): Promise { - return this.repository.findOne({ where: { id } }); + @GenerateSql({ params: [DummyValue.UUID] }) + get(id: string) { + return this.db.selectFrom('tags').select(columns.tagDto).where('id', '=', id).executeTakeFirst(); } - getByValue(userId: string, value: string): Promise { - return this.repository.findOne({ where: { userId, value } }); + @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) + getByValue(userId: string, value: string) { + return this.db + .selectFrom('tags') + .select(columns.tagDto) + .where('userId', '=', userId) + .where('value', '=', value) + .executeTakeFirst(); } - async upsertValue({ - userId, - value, - parent, - }: { - userId: string; - value: string; - parent?: TagEntity; - }): Promise { - return this.dataSource.transaction(async (manager) => { - // upsert tag - const { identifiers } = await manager.upsert( - TagEntity, - { userId, value, parentId: parent?.id }, - { conflictPaths: { userId: true, value: true } }, - ); - const id = identifiers[0]?.id; - if (!id) { - throw new Error('Failed to upsert tag'); - } + @GenerateSql({ params: [{ userId: DummyValue.UUID, value: DummyValue.STRING, parentId: DummyValue.UUID }] }) + async upsertValue({ userId, value, parentId: _parentId }: { userId: string; value: string; parentId?: string }) { + const parentId = _parentId ?? null; + return this.db.transaction().execute(async (tx) => { + const tag = await this.db + .insertInto('tags') + .values({ userId, value, parentId }) + .onConflict((oc) => oc.columns(['userId', 'value']).doUpdateSet({ parentId })) + .returningAll() + .executeTakeFirstOrThrow(); // update closure table - await manager.query( - `INSERT INTO tags_closure (id_ancestor, id_descendant) - VALUES ($1, $1) - ON CONFLICT DO NOTHING;`, - [id], - ); + await tx + .insertInto('tags_closure') + .values({ id_ancestor: tag.id, id_descendant: tag.id }) + .onConflict((oc) => oc.doNothing()) + .execute(); - if (parent) { - await manager.query( - `INSERT INTO tags_closure (id_ancestor, id_descendant) - SELECT id_ancestor, '${id}' as id_descendant FROM tags_closure WHERE id_descendant = $1 - ON CONFLICT DO NOTHING`, - [parent.id], - ); + if (parentId) { + await tx + .insertInto('tags_closure') + .columns(['id_ancestor', 'id_descendant']) + .expression( + this.db + .selectFrom('tags_closure') + .select(['id_ancestor', sql.raw(`'${tag.id}'`).as('id_descendant')]) + .where('id_descendant', '=', parentId), + ) + .onConflict((oc) => oc.doNothing()) + .execute(); } - return manager.findOneOrFail(TagEntity, { where: { id } }); + return tag; }); } - async getAll(userId: string): Promise { - const tags = await this.repository.find({ - where: { userId }, - order: { - value: 'ASC', - }, - }); - - return tags; + @GenerateSql({ params: [DummyValue.UUID] }) + getAll(userId: string) { + return this.db + .selectFrom('tags') + .select(columns.tagDto) + .where('userId', '=', userId) + .orderBy('value asc') + .execute(); } - create(tag: Partial): Promise { - return this.save(tag); + @GenerateSql({ params: [{ userId: DummyValue.UUID, color: DummyValue.STRING, value: DummyValue.STRING }] }) + create(tag: Insertable) { + return this.db.insertInto('tags').values(tag).returningAll().executeTakeFirstOrThrow(); } - update(tag: Partial): Promise { - return this.save(tag); + @GenerateSql({ params: [DummyValue.UUID, { color: DummyValue.STRING }] }) + update(id: string, dto: Updateable) { + return this.db.updateTable('tags').set(dto).where('id', '=', id).returningAll().executeTakeFirstOrThrow(); } - async delete(id: string): Promise { - await this.repository.delete(id); + @GenerateSql({ params: [DummyValue.UUID] }) + async delete(id: string) { + await this.db.deleteFrom('tags').where('id', '=', id).execute(); } - @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) @ChunkedSet({ paramIndex: 1 }) + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) async getAssetIds(tagId: string, assetIds: string[]): Promise> { if (assetIds.length === 0) { return new Set(); } - const results = await this.dataSource - .createQueryBuilder() - .select('tag_asset.assetsId', 'assetId') - .from('tag_asset', 'tag_asset') - .where('"tag_asset"."tagsId" = :tagId', { tagId }) - .andWhere('"tag_asset"."assetsId" IN (:...assetIds)', { assetIds }) - .getRawMany<{ assetId: string }>(); + const results = await this.db + .selectFrom('tag_asset') + .select(['assetsId as assetId']) + .where('tagsId', '=', tagId) + .where('assetsId', 'in', assetIds) + .execute(); return new Set(results.map(({ assetId }) => assetId)); } + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) + @Chunked({ paramIndex: 1 }) async addAssetIds(tagId: string, assetIds: string[]): Promise { if (assetIds.length === 0) { return; } - await this.dataSource.manager - .createQueryBuilder() - .insert() - .into('tag_asset', ['tagsId', 'assetsId']) + await this.db + .insertInto('tag_asset') .values(assetIds.map((assetId) => ({ tagsId: tagId, assetsId: assetId }))) .execute(); } + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) @Chunked({ paramIndex: 1 }) async removeAssetIds(tagId: string, assetIds: string[]): Promise { if (assetIds.length === 0) { return; } - await this.dataSource - .createQueryBuilder() - .delete() - .from('tag_asset') - .where({ - tagsId: tagId, - assetsId: In(assetIds), - }) - .execute(); + await this.db.deleteFrom('tag_asset').where('tagsId', '=', tagId).where('assetsId', 'in', assetIds).execute(); } + @GenerateSql({ params: [{ assetId: DummyValue.UUID, tagsIds: [DummyValue.UUID] }] }) @Chunked() - async upsertAssetIds(items: AssetTagItem[]): Promise { + upsertAssetIds(items: Insertable[]) { if (items.length === 0) { - return []; + return Promise.resolve([]); } - const { identifiers } = await this.dataSource - .createQueryBuilder() - .insert() - .into('tag_asset', ['assetsId', 'tagsId']) - .values(items.map(({ assetId, tagId }) => ({ assetsId: assetId, tagsId: tagId }))) + return this.db + .insertInto('tag_asset') + .values(items) + .onConflict((oc) => oc.doNothing()) + .returningAll() .execute(); - - return (identifiers as Array<{ assetsId: string; tagsId: string }>).map(({ assetsId, tagsId }) => ({ - assetId: assetsId, - tagId: tagsId, - })); } - async upsertAssetTags({ assetId, tagIds }: { assetId: string; tagIds: string[] }) { - await this.dataSource.transaction(async (manager) => { - await manager.createQueryBuilder().delete().from('tag_asset').where({ assetsId: assetId }).execute(); + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) + @Chunked({ paramIndex: 1 }) + replaceAssetTags(assetId: string, tagIds: string[]) { + return this.db.transaction().execute(async (tx) => { + await tx.deleteFrom('tag_asset').where('assetsId', '=', assetId).execute(); if (tagIds.length === 0) { return; } - await manager - .createQueryBuilder() - .insert() - .into('tag_asset', ['tagsId', 'assetsId']) + return tx + .insertInto('tag_asset') .values(tagIds.map((tagId) => ({ tagsId: tagId, assetsId: assetId }))) + .onConflict((oc) => oc.doNothing()) + .returningAll() .execute(); }); } + @GenerateSql() async deleteEmptyTags() { - await this.dataSource.transaction(async (manager) => { - const ids = new Set(); - const tags = await manager.find(TagEntity); - for (const tag of tags) { - const count = await manager - .createQueryBuilder('assets', 'asset') - .innerJoin( - 'asset.tags', - 'asset_tags', - 'asset_tags.id IN (SELECT id_descendant FROM tags_closure WHERE id_ancestor = :tagId)', - { tagId: tag.id }, - ) - .getCount(); + // TODO rewrite as a single statement + await this.db.transaction().execute(async (tx) => { + const result = await tx + .selectFrom('assets') + .innerJoin('tag_asset', 'tag_asset.assetsId', 'assets.id') + .innerJoin('tags_closure', 'tags_closure.id_descendant', 'tag_asset.tagsId') + .innerJoin('tags', 'tags.id', 'tags_closure.id_descendant') + .select((eb) => ['tags.id', eb.fn.count('assets.id').as('count')]) + .groupBy('tags.id') + .execute(); - if (count === 0) { - this.logger.debug(`Found empty tag: ${tag.id} - ${tag.value}`); - ids.add(tag.id); - } - } - - if (ids.size > 0) { - await manager.delete(TagEntity, { id: In([...ids]) }); - this.logger.log(`Deleted ${ids.size} empty tags`); + const ids = result.filter(({ count }) => count === 0).map(({ id }) => id); + if (ids.length > 0) { + await this.db.deleteFrom('tags').where('id', 'in', ids).execute(); + this.logger.log(`Deleted ${ids.length} empty tags`); } }); } - - private async save(partial: Partial): Promise { - const { id } = await this.repository.save(partial); - return this.repository.findOneOrFail({ where: { id } }); - } } diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index e7c65b3f01..1387828be6 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -3,14 +3,9 @@ import { Insertable, Kysely, sql, Updateable } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { DB, UserMetadata as DbUserMetadata, Users } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { UserMetadata } from 'src/entities/user-metadata.entity'; +import { UserMetadata, UserMetadataItem } from 'src/entities/user-metadata.entity'; import { UserEntity, withMetadata } from 'src/entities/user.entity'; -import { - IUserRepository, - UserFindOptions, - UserListFilter, - UserStatsQueryResponse, -} from 'src/interfaces/user.interface'; +import { AssetType, UserStatus } from 'src/enum'; import { asUuid } from 'src/utils/database'; const columns = [ @@ -33,8 +28,27 @@ const columns = [ type Upsert = Insertable; +export interface UserListFilter { + withDeleted?: boolean; +} + +export interface UserStatsQueryResponse { + userId: string; + userName: string; + photos: number; + videos: number; + usage: number; + usagePhotos: number; + usageVideos: number; + quotaSizeInBytes: number | null; +} + +export interface UserFindOptions { + withDeleted?: boolean; +} + @Injectable() -export class UserRepository implements IUserRepository { +export class UserRepository { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID, DummyValue.BOOLEAN] }) @@ -50,6 +64,14 @@ export class UserRepository implements IUserRepository { .executeTakeFirst() as Promise; } + getMetadata(userId: string) { + return this.db + .selectFrom('user_metadata') + .select(['key', 'value']) + .where('user_metadata.userId', '=', userId) + .execute() as Promise; + } + @GenerateSql() getAdmin(): Promise { return this.db @@ -140,6 +162,16 @@ export class UserRepository implements IUserRepository { .executeTakeFirst() as unknown as Promise; } + restore(id: string): Promise { + return this.db + .updateTable('users') + .set({ status: UserStatus.ACTIVE, deletedAt: null }) + .where('users.id', '=', asUuid(id)) + .returning(columns) + .returning(withMetadata) + .executeTakeFirst() as unknown as Promise; + } + async upsertMetadata(id: string, { key, value }: { key: T; value: UserMetadata[T] }) { await this.db .insertInto('user_metadata') @@ -157,7 +189,7 @@ export class UserRepository implements IUserRepository { await this.db.deleteFrom('user_metadata').where('userId', '=', id).where('key', '=', key).execute(); } - delete(user: UserEntity, hard?: boolean): Promise { + delete(user: { id: string }, hard?: boolean): Promise { return hard ? (this.db.deleteFrom('users').where('id', '=', user.id).execute() as unknown as Promise) : (this.db @@ -177,11 +209,11 @@ export class UserRepository implements IUserRepository { .select((eb) => [ eb.fn .countAll() - .filterWhere((eb) => eb.and([eb('assets.type', '=', 'IMAGE'), eb('assets.isVisible', '=', true)])) + .filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.IMAGE), eb('assets.isVisible', '=', true)])) .as('photos'), eb.fn .countAll() - .filterWhere((eb) => eb.and([eb('assets.type', '=', 'VIDEO'), eb('assets.isVisible', '=', true)])) + .filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.VIDEO), eb('assets.isVisible', '=', true)])) .as('videos'), eb.fn .coalesce(eb.fn.sum('exif.fileSizeInByte').filterWhere('assets.libraryId', 'is', null), eb.lit(0)) @@ -190,7 +222,9 @@ export class UserRepository implements IUserRepository { .coalesce( eb.fn .sum('exif.fileSizeInByte') - .filterWhere((eb) => eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', 'IMAGE')])), + .filterWhere((eb) => + eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', AssetType.IMAGE)]), + ), eb.lit(0), ) .as('usagePhotos'), @@ -198,7 +232,9 @@ export class UserRepository implements IUserRepository { .coalesce( eb.fn .sum('exif.fileSizeInByte') - .filterWhere((eb) => eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', 'VIDEO')])), + .filterWhere((eb) => + eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', AssetType.VIDEO)]), + ), eb.lit(0), ) .as('usageVideos'), @@ -239,7 +275,7 @@ export class UserRepository implements IUserRepository { eb .selectFrom('assets') .leftJoin('exif', 'exif.assetId', 'assets.id') - .select((eb) => eb.fn.coalesce(eb.fn.sum('exif.fileSizeInByte'), eb.lit(0)).as('usage')) + .select((eb) => eb.fn.coalesce(eb.fn.sum('exif.fileSizeInByte'), eb.lit(0)).as('usage')) .where('assets.libraryId', 'is', null) .where('assets.ownerId', '=', eb.ref('users.id')), updatedAt: new Date(), diff --git a/server/src/repositories/view-repository.ts b/server/src/repositories/view-repository.ts index f24b1bac6e..ae2303e9e2 100644 --- a/server/src/repositories/view-repository.ts +++ b/server/src/repositories/view-repository.ts @@ -18,6 +18,9 @@ export class ViewRepository { .where('isVisible', '=', true) .where('isArchived', '=', false) .where('deletedAt', 'is', null) + .where('fileCreatedAt', 'is not', null) + .where('fileModifiedAt', 'is not', null) + .where('localDateTime', 'is not', null) .execute(); return results.map((row) => row.directoryPath.replaceAll(/^\/|\/$/g, '')); @@ -35,6 +38,9 @@ export class ViewRepository { .where('isVisible', '=', true) .where('isArchived', '=', false) .where('deletedAt', 'is', null) + .where('fileCreatedAt', 'is not', null) + .where('fileModifiedAt', 'is not', null) + .where('localDateTime', 'is not', null) .where('originalPath', 'like', `%${normalizedPath}/%`) .where('originalPath', 'not like', `%${normalizedPath}/%/%`) .orderBy( diff --git a/server/src/services/activity.service.spec.ts b/server/src/services/activity.service.spec.ts index 4ee656abe5..1e5bb3b505 100644 --- a/server/src/services/activity.service.spec.ts +++ b/server/src/services/activity.service.spec.ts @@ -1,21 +1,15 @@ import { BadRequestException } from '@nestjs/common'; import { ReactionType } from 'src/dtos/activity.dto'; import { ActivityService } from 'src/services/activity.service'; -import { IActivityRepository } from 'src/types'; -import { activityStub } from 'test/fixtures/activity.stub'; -import { authStub } from 'test/fixtures/auth.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { factory, newUuid, newUuids } from 'test/small.factory'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(ActivityService.name, () => { let sut: ActivityService; - - let accessMock: IAccessRepositoryMock; - let activityMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, activityMock } = newTestService(ActivityService)); + ({ sut, mocks } = newTestService(ActivityService)); }); it('should work', () => { @@ -24,158 +18,150 @@ describe(ActivityService.name, () => { describe('getAll', () => { it('should get all', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); - activityMock.search.mockResolvedValue([]); + const [albumId, assetId, userId] = newUuids(); - await expect(sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id' })).resolves.toEqual([]); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); + mocks.activity.search.mockResolvedValue([]); - expect(activityMock.search).toHaveBeenCalledWith({ - assetId: 'asset-id', - albumId: 'album-id', - isLiked: undefined, - }); + await expect(sut.getAll(factory.auth({ id: userId }), { assetId, albumId })).resolves.toEqual([]); + + expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: undefined }); }); it('should filter by type=like', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); - activityMock.search.mockResolvedValue([]); + const [albumId, assetId, userId] = newUuids(); + + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); + mocks.activity.search.mockResolvedValue([]); await expect( - sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id', type: ReactionType.LIKE }), + sut.getAll(factory.auth({ id: userId }), { assetId, albumId, type: ReactionType.LIKE }), ).resolves.toEqual([]); - expect(activityMock.search).toHaveBeenCalledWith({ - assetId: 'asset-id', - albumId: 'album-id', - isLiked: true, - }); + expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: true }); }); it('should filter by type=comment', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); - activityMock.search.mockResolvedValue([]); + const [albumId, assetId] = newUuids(); - await expect( - sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id', type: ReactionType.COMMENT }), - ).resolves.toEqual([]); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); + mocks.activity.search.mockResolvedValue([]); - expect(activityMock.search).toHaveBeenCalledWith({ - assetId: 'asset-id', - albumId: 'album-id', - isLiked: false, - }); + await expect(sut.getAll(factory.auth(), { assetId, albumId, type: ReactionType.COMMENT })).resolves.toEqual([]); + + expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: false }); }); }); describe('getStatistics', () => { it('should get the comment count', async () => { - activityMock.getStatistics.mockResolvedValue(1); - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([activityStub.oneComment.albumId])); - await expect( - sut.getStatistics(authStub.admin, { - assetId: 'asset-id', - albumId: activityStub.oneComment.albumId, - }), - ).resolves.toEqual({ comments: 1 }); + const [albumId, assetId] = newUuids(); + + mocks.activity.getStatistics.mockResolvedValue(1); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); + + await expect(sut.getStatistics(factory.auth(), { assetId, albumId })).resolves.toEqual({ comments: 1 }); }); }); describe('addComment', () => { it('should require access to the album', async () => { + const [albumId, assetId] = newUuids(); + await expect( - sut.create(authStub.admin, { - albumId: 'album-id', - assetId: 'asset-id', - type: ReactionType.COMMENT, - comment: 'comment', - }), + sut.create(factory.auth(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }), ).rejects.toBeInstanceOf(BadRequestException); }); it('should create a comment', async () => { - accessMock.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id'])); - activityMock.create.mockResolvedValue(activityStub.oneComment); + const [albumId, assetId, userId] = newUuids(); + const activity = factory.activity({ albumId, assetId, userId }); - await sut.create(authStub.admin, { - albumId: 'album-id', - assetId: 'asset-id', + mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId])); + mocks.activity.create.mockResolvedValue(activity); + + await sut.create(factory.auth({ id: userId }), { + albumId, + assetId, type: ReactionType.COMMENT, comment: 'comment', }); - expect(activityMock.create).toHaveBeenCalledWith({ - userId: 'admin_id', - albumId: 'album-id', - assetId: 'asset-id', + expect(mocks.activity.create).toHaveBeenCalledWith({ + userId: activity.userId, + albumId: activity.albumId, + assetId: activity.assetId, comment: 'comment', isLiked: false, }); }); it('should fail because activity is disabled for the album', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); - activityMock.create.mockResolvedValue(activityStub.oneComment); + const [albumId, assetId] = newUuids(); + const activity = factory.activity({ albumId, assetId }); + + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); + mocks.activity.create.mockResolvedValue(activity); await expect( - sut.create(authStub.admin, { - albumId: 'album-id', - assetId: 'asset-id', - type: ReactionType.COMMENT, - comment: 'comment', - }), + sut.create(factory.auth(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }), ).rejects.toBeInstanceOf(BadRequestException); }); it('should create a like', async () => { - accessMock.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id'])); - activityMock.create.mockResolvedValue(activityStub.liked); - activityMock.search.mockResolvedValue([]); + const [albumId, assetId, userId] = newUuids(); + const activity = factory.activity({ userId, albumId, assetId, isLiked: true }); - await sut.create(authStub.admin, { - albumId: 'album-id', - assetId: 'asset-id', - type: ReactionType.LIKE, - }); + mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId])); + mocks.activity.create.mockResolvedValue(activity); + mocks.activity.search.mockResolvedValue([]); - expect(activityMock.create).toHaveBeenCalledWith({ - userId: 'admin_id', - albumId: 'album-id', - assetId: 'asset-id', - isLiked: true, - }); + await sut.create(factory.auth({ id: userId }), { albumId, assetId, type: ReactionType.LIKE }); + + expect(mocks.activity.create).toHaveBeenCalledWith({ userId: activity.userId, albumId, assetId, isLiked: true }); }); it('should skip if like exists', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); - accessMock.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id'])); - activityMock.search.mockResolvedValue([activityStub.liked]); + const [albumId, assetId] = newUuids(); + const activity = factory.activity({ albumId, assetId, isLiked: true }); - await sut.create(authStub.admin, { - albumId: 'album-id', - assetId: 'asset-id', - type: ReactionType.LIKE, - }); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); + mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId])); + mocks.activity.search.mockResolvedValue([activity]); - expect(activityMock.create).not.toHaveBeenCalled(); + await sut.create(factory.auth(), { albumId, assetId, type: ReactionType.LIKE }); + + expect(mocks.activity.create).not.toHaveBeenCalled(); }); }); describe('delete', () => { it('should require access', async () => { - await expect(sut.delete(authStub.admin, activityStub.oneComment.id)).rejects.toBeInstanceOf(BadRequestException); - expect(activityMock.delete).not.toHaveBeenCalled(); + await expect(sut.delete(factory.auth(), newUuid())).rejects.toBeInstanceOf(BadRequestException); + + expect(mocks.activity.delete).not.toHaveBeenCalled(); }); it('should let the activity owner delete a comment', async () => { - accessMock.activity.checkOwnerAccess.mockResolvedValue(new Set(['activity-id'])); - await sut.delete(authStub.admin, 'activity-id'); - expect(activityMock.delete).toHaveBeenCalledWith('activity-id'); + const activity = factory.activity(); + + mocks.access.activity.checkOwnerAccess.mockResolvedValue(new Set([activity.id])); + mocks.activity.delete.mockResolvedValue(); + + await sut.delete(factory.auth(), activity.id); + + expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id); }); it('should let the album owner delete a comment', async () => { - accessMock.activity.checkAlbumOwnerAccess.mockResolvedValue(new Set(['activity-id'])); - await sut.delete(authStub.admin, 'activity-id'); - expect(activityMock.delete).toHaveBeenCalledWith('activity-id'); + const activity = factory.activity(); + + mocks.access.activity.checkAlbumOwnerAccess.mockResolvedValue(new Set([activity.id])); + mocks.activity.delete.mockResolvedValue(); + + await sut.delete(factory.auth(), activity.id); + + expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id); }); }); }); diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index fe732843b6..62d1326cab 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -2,29 +2,18 @@ import { BadRequestException } from '@nestjs/common'; import _ from 'lodash'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { AlbumUserRole } from 'src/enum'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; import { AlbumService } from 'src/services/album.service'; -import { IAlbumUserRepository } from 'src/types'; import { albumStub } from 'test/fixtures/album.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(AlbumService.name, () => { let sut: AlbumService; - - let accessMock: IAccessRepositoryMock; - let albumMock: Mocked; - let albumUserMock: Mocked; - let eventMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, albumMock, albumUserMock, eventMock, userMock } = newTestService(AlbumService)); + ({ sut, mocks } = newTestService(AlbumService)); }); it('should work', () => { @@ -33,27 +22,27 @@ describe(AlbumService.name, () => { describe('getStatistics', () => { it('should get the album count', async () => { - albumMock.getOwned.mockResolvedValue([]); - albumMock.getShared.mockResolvedValue([]); - albumMock.getNotShared.mockResolvedValue([]); + mocks.album.getOwned.mockResolvedValue([]); + mocks.album.getShared.mockResolvedValue([]); + mocks.album.getNotShared.mockResolvedValue([]); await expect(sut.getStatistics(authStub.admin)).resolves.toEqual({ owned: 0, shared: 0, notShared: 0, }); - expect(albumMock.getOwned).toHaveBeenCalledWith(authStub.admin.user.id); - expect(albumMock.getShared).toHaveBeenCalledWith(authStub.admin.user.id); - expect(albumMock.getNotShared).toHaveBeenCalledWith(authStub.admin.user.id); + expect(mocks.album.getOwned).toHaveBeenCalledWith(authStub.admin.user.id); + expect(mocks.album.getShared).toHaveBeenCalledWith(authStub.admin.user.id); + expect(mocks.album.getNotShared).toHaveBeenCalledWith(authStub.admin.user.id); }); }); describe('getAll', () => { it('gets list of albums for auth user', async () => { - albumMock.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]); - albumMock.getMetadataForIds.mockResolvedValue([ - { albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined }, - { albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined }, + mocks.album.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]); + mocks.album.getMetadataForIds.mockResolvedValue([ + { albumId: albumStub.empty.id, assetCount: 0, startDate: null, endDate: null }, + { albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: null, endDate: null }, ]); const result = await sut.getAll(authStub.admin, {}); @@ -63,8 +52,8 @@ describe(AlbumService.name, () => { }); it('gets list of albums that have a specific asset', async () => { - albumMock.getByAssetId.mockResolvedValue([albumStub.oneAsset]); - albumMock.getMetadataForIds.mockResolvedValue([ + mocks.album.getByAssetId.mockResolvedValue([albumStub.oneAsset]); + mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: albumStub.oneAsset.id, assetCount: 1, @@ -76,37 +65,37 @@ describe(AlbumService.name, () => { const result = await sut.getAll(authStub.admin, { assetId: albumStub.oneAsset.id }); expect(result).toHaveLength(1); expect(result[0].id).toEqual(albumStub.oneAsset.id); - expect(albumMock.getByAssetId).toHaveBeenCalledTimes(1); + expect(mocks.album.getByAssetId).toHaveBeenCalledTimes(1); }); it('gets list of albums that are shared', async () => { - albumMock.getShared.mockResolvedValue([albumStub.sharedWithUser]); - albumMock.getMetadataForIds.mockResolvedValue([ - { albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined }, + mocks.album.getShared.mockResolvedValue([albumStub.sharedWithUser]); + mocks.album.getMetadataForIds.mockResolvedValue([ + { albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: null, endDate: null }, ]); const result = await sut.getAll(authStub.admin, { shared: true }); expect(result).toHaveLength(1); expect(result[0].id).toEqual(albumStub.sharedWithUser.id); - expect(albumMock.getShared).toHaveBeenCalledTimes(1); + expect(mocks.album.getShared).toHaveBeenCalledTimes(1); }); it('gets list of albums that are NOT shared', async () => { - albumMock.getNotShared.mockResolvedValue([albumStub.empty]); - albumMock.getMetadataForIds.mockResolvedValue([ - { albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined }, + mocks.album.getNotShared.mockResolvedValue([albumStub.empty]); + mocks.album.getMetadataForIds.mockResolvedValue([ + { albumId: albumStub.empty.id, assetCount: 0, startDate: null, endDate: null }, ]); const result = await sut.getAll(authStub.admin, { shared: false }); expect(result).toHaveLength(1); expect(result[0].id).toEqual(albumStub.empty.id); - expect(albumMock.getNotShared).toHaveBeenCalledTimes(1); + expect(mocks.album.getNotShared).toHaveBeenCalledTimes(1); }); }); it('counts assets correctly', async () => { - albumMock.getOwned.mockResolvedValue([albumStub.oneAsset]); - albumMock.getMetadataForIds.mockResolvedValue([ + mocks.album.getOwned.mockResolvedValue([albumStub.oneAsset]); + mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: albumStub.oneAsset.id, assetCount: 1, @@ -119,14 +108,14 @@ describe(AlbumService.name, () => { expect(result).toHaveLength(1); expect(result[0].assetCount).toEqual(1); - expect(albumMock.getOwned).toHaveBeenCalledTimes(1); + expect(mocks.album.getOwned).toHaveBeenCalledTimes(1); }); describe('create', () => { it('creates album', async () => { - albumMock.create.mockResolvedValue(albumStub.empty); - userMock.get.mockResolvedValue(userStub.user1); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['123'])); + mocks.album.create.mockResolvedValue(albumStub.empty); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['123'])); await sut.create(authStub.admin, { albumName: 'Empty album', @@ -135,7 +124,7 @@ describe(AlbumService.name, () => { assetIds: ['123'], }); - expect(albumMock.create).toHaveBeenCalledWith( + expect(mocks.album.create).toHaveBeenCalledWith( { ownerId: authStub.admin.user.id, albumName: albumStub.empty.albumName, @@ -147,30 +136,30 @@ describe(AlbumService.name, () => { [{ userId: 'user-id', role: AlbumUserRole.EDITOR }], ); - expect(userMock.get).toHaveBeenCalledWith('user-id', {}); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123'])); - expect(eventMock.emit).toHaveBeenCalledWith('album.invite', { + expect(mocks.user.get).toHaveBeenCalledWith('user-id', {}); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123'])); + expect(mocks.event.emit).toHaveBeenCalledWith('album.invite', { id: albumStub.empty.id, userId: 'user-id', }); }); it('should require valid userIds', async () => { - userMock.get.mockResolvedValue(void 0); + mocks.user.get.mockResolvedValue(void 0); await expect( sut.create(authStub.admin, { albumName: 'Empty album', albumUsers: [{ userId: 'user-3', role: AlbumUserRole.EDITOR }], }), ).rejects.toBeInstanceOf(BadRequestException); - expect(userMock.get).toHaveBeenCalledWith('user-3', {}); - expect(albumMock.create).not.toHaveBeenCalled(); + expect(mocks.user.get).toHaveBeenCalledWith('user-3', {}); + expect(mocks.album.create).not.toHaveBeenCalled(); }); it('should only add assets the user is allowed to access', async () => { - userMock.get.mockResolvedValue(userStub.user1); - albumMock.create.mockResolvedValue(albumStub.oneAsset); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.album.create.mockResolvedValue(albumStub.oneAsset); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.create(authStub.admin, { albumName: 'Test album', @@ -178,7 +167,7 @@ describe(AlbumService.name, () => { assetIds: ['asset-1', 'asset-2'], }); - expect(albumMock.create).toHaveBeenCalledWith( + expect(mocks.album.create).toHaveBeenCalledWith( { ownerId: authStub.admin.user.id, albumName: 'Test album', @@ -189,7 +178,7 @@ describe(AlbumService.name, () => { ['asset-1'], [], ); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith( + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set(['asset-1', 'asset-2']), ); @@ -198,7 +187,7 @@ describe(AlbumService.name, () => { describe('update', () => { it('should prevent updating an album that does not exist', async () => { - albumMock.getById.mockResolvedValue(void 0); + mocks.album.getById.mockResolvedValue(void 0); await expect( sut.update(authStub.user1, 'invalid-id', { @@ -206,7 +195,7 @@ describe(AlbumService.name, () => { }), ).rejects.toBeInstanceOf(BadRequestException); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should prevent updating a not owned album (shared with auth user)', async () => { @@ -218,10 +207,10 @@ describe(AlbumService.name, () => { }); it('should require a valid thumbnail asset id', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-4'])); - albumMock.getById.mockResolvedValue(albumStub.oneAsset); - albumMock.update.mockResolvedValue(albumStub.oneAsset); - albumMock.getAssetIds.mockResolvedValue(new Set()); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-4'])); + mocks.album.getById.mockResolvedValue(albumStub.oneAsset); + mocks.album.update.mockResolvedValue(albumStub.oneAsset); + mocks.album.getAssetIds.mockResolvedValue(new Set()); await expect( sut.update(authStub.admin, albumStub.oneAsset.id, { @@ -229,22 +218,22 @@ describe(AlbumService.name, () => { }), ).rejects.toBeInstanceOf(BadRequestException); - expect(albumMock.getAssetIds).toHaveBeenCalledWith('album-4', ['not-in-album']); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.getAssetIds).toHaveBeenCalledWith('album-4', ['not-in-album']); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should allow the owner to update the album', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-4'])); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-4'])); - albumMock.getById.mockResolvedValue(albumStub.oneAsset); - albumMock.update.mockResolvedValue(albumStub.oneAsset); + mocks.album.getById.mockResolvedValue(albumStub.oneAsset); + mocks.album.update.mockResolvedValue(albumStub.oneAsset); await sut.update(authStub.admin, albumStub.oneAsset.id, { albumName: 'new album name', }); - expect(albumMock.update).toHaveBeenCalledTimes(1); - expect(albumMock.update).toHaveBeenCalledWith('album-4', { + expect(mocks.album.update).toHaveBeenCalledTimes(1); + expect(mocks.album.update).toHaveBeenCalledWith('album-4', { id: 'album-4', albumName: 'new album name', }); @@ -253,33 +242,33 @@ describe(AlbumService.name, () => { describe('delete', () => { it('should throw an error for an album not found', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set()); await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf( BadRequestException, ); - expect(albumMock.delete).not.toHaveBeenCalled(); + expect(mocks.album.delete).not.toHaveBeenCalled(); }); it('should not let a shared user delete the album', async () => { - albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin); + mocks.album.getById.mockResolvedValue(albumStub.sharedWithAdmin); await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf( BadRequestException, ); - expect(albumMock.delete).not.toHaveBeenCalled(); + expect(mocks.album.delete).not.toHaveBeenCalled(); }); it('should let the owner delete an album', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.empty.id])); - albumMock.getById.mockResolvedValue(albumStub.empty); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.empty.id])); + mocks.album.getById.mockResolvedValue(albumStub.empty); await sut.delete(authStub.admin, albumStub.empty.id); - expect(albumMock.delete).toHaveBeenCalledTimes(1); - expect(albumMock.delete).toHaveBeenCalledWith(albumStub.empty.id); + expect(mocks.album.delete).toHaveBeenCalledTimes(1); + expect(mocks.album.delete).toHaveBeenCalledWith(albumStub.empty.id); }); }); @@ -288,47 +277,47 @@ describe(AlbumService.name, () => { await expect( sut.addUsers(authStub.admin, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: 'user-1' }] }), ).rejects.toBeInstanceOf(BadRequestException); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should throw an error if the userId is already added', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); - albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); + mocks.album.getById.mockResolvedValue(albumStub.sharedWithAdmin); await expect( sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: authStub.admin.user.id }], }), ).rejects.toBeInstanceOf(BadRequestException); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should throw an error if the userId does not exist', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); - albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin); - userMock.get.mockResolvedValue(void 0); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); + mocks.album.getById.mockResolvedValue(albumStub.sharedWithAdmin); + mocks.user.get.mockResolvedValue(void 0); await expect( sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: 'user-3' }] }), ).rejects.toBeInstanceOf(BadRequestException); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should throw an error if the userId is the ownerId', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); - albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); + mocks.album.getById.mockResolvedValue(albumStub.sharedWithAdmin); await expect( sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: userStub.user1.id }], }), ).rejects.toBeInstanceOf(BadRequestException); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should add valid shared users', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithAdmin)); - albumMock.update.mockResolvedValue(albumStub.sharedWithAdmin); - userMock.get.mockResolvedValue(userStub.user2); - albumUserMock.create.mockResolvedValue({ + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithAdmin)); + mocks.album.update.mockResolvedValue(albumStub.sharedWithAdmin); + mocks.user.get.mockResolvedValue(userStub.user2); + mocks.albumUser.create.mockResolvedValue({ usersId: userStub.user2.id, albumsId: albumStub.sharedWithAdmin.id, role: AlbumUserRole.EDITOR, @@ -336,11 +325,11 @@ describe(AlbumService.name, () => { await sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: authStub.user2.user.id }], }); - expect(albumUserMock.create).toHaveBeenCalledWith({ + expect(mocks.albumUser.create).toHaveBeenCalledWith({ usersId: authStub.user2.user.id, albumsId: albumStub.sharedWithAdmin.id, }); - expect(eventMock.emit).toHaveBeenCalledWith('album.invite', { + expect(mocks.event.emit).toHaveBeenCalledWith('album.invite', { id: albumStub.sharedWithAdmin.id, userId: userStub.user2.id, }); @@ -349,94 +338,99 @@ describe(AlbumService.name, () => { describe('removeUser', () => { it('should require a valid album id', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-1'])); - albumMock.getById.mockResolvedValue(void 0); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-1'])); + mocks.album.getById.mockResolvedValue(void 0); await expect(sut.removeUser(authStub.admin, 'album-1', 'user-1')).rejects.toBeInstanceOf(BadRequestException); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should remove a shared user from an owned album', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithUser.id])); - albumMock.getById.mockResolvedValue(albumStub.sharedWithUser); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithUser.id])); + mocks.album.getById.mockResolvedValue(albumStub.sharedWithUser); + mocks.albumUser.delete.mockResolvedValue(); await expect( sut.removeUser(authStub.admin, albumStub.sharedWithUser.id, userStub.user1.id), ).resolves.toBeUndefined(); - expect(albumUserMock.delete).toHaveBeenCalledTimes(1); - expect(albumUserMock.delete).toHaveBeenCalledWith({ + expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1); + expect(mocks.albumUser.delete).toHaveBeenCalledWith({ albumsId: albumStub.sharedWithUser.id, usersId: userStub.user1.id, }); - expect(albumMock.getById).toHaveBeenCalledWith(albumStub.sharedWithUser.id, { withAssets: false }); + expect(mocks.album.getById).toHaveBeenCalledWith(albumStub.sharedWithUser.id, { withAssets: false }); }); it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => { - albumMock.getById.mockResolvedValue(albumStub.sharedWithMultiple); + mocks.album.getById.mockResolvedValue(albumStub.sharedWithMultiple); await expect( sut.removeUser(authStub.user1, albumStub.sharedWithMultiple.id, authStub.user2.user.id), ).rejects.toBeInstanceOf(BadRequestException); - expect(albumUserMock.delete).not.toHaveBeenCalled(); - expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith( + expect(mocks.albumUser.delete).not.toHaveBeenCalled(); + expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith( authStub.user1.user.id, new Set([albumStub.sharedWithMultiple.id]), ); }); it('should allow a shared user to remove themselves', async () => { - albumMock.getById.mockResolvedValue(albumStub.sharedWithUser); + mocks.album.getById.mockResolvedValue(albumStub.sharedWithUser); + mocks.albumUser.delete.mockResolvedValue(); await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, authStub.user1.user.id); - expect(albumUserMock.delete).toHaveBeenCalledTimes(1); - expect(albumUserMock.delete).toHaveBeenCalledWith({ + expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1); + expect(mocks.albumUser.delete).toHaveBeenCalledWith({ albumsId: albumStub.sharedWithUser.id, usersId: authStub.user1.user.id, }); }); it('should allow a shared user to remove themselves using "me"', async () => { - albumMock.getById.mockResolvedValue(albumStub.sharedWithUser); + mocks.album.getById.mockResolvedValue(albumStub.sharedWithUser); + mocks.albumUser.delete.mockResolvedValue(); await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, 'me'); - expect(albumUserMock.delete).toHaveBeenCalledTimes(1); - expect(albumUserMock.delete).toHaveBeenCalledWith({ + expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1); + expect(mocks.albumUser.delete).toHaveBeenCalledWith({ albumsId: albumStub.sharedWithUser.id, usersId: authStub.user1.user.id, }); }); it('should not allow the owner to be removed', async () => { - albumMock.getById.mockResolvedValue(albumStub.empty); + mocks.album.getById.mockResolvedValue(albumStub.empty); await expect(sut.removeUser(authStub.admin, albumStub.empty.id, authStub.admin.user.id)).rejects.toBeInstanceOf( BadRequestException, ); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should throw an error for a user not in the album', async () => { - albumMock.getById.mockResolvedValue(albumStub.empty); + mocks.album.getById.mockResolvedValue(albumStub.empty); await expect(sut.removeUser(authStub.admin, albumStub.empty.id, 'user-3')).rejects.toBeInstanceOf( BadRequestException, ); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); }); describe('updateUser', () => { it('should update user role', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); + mocks.albumUser.update.mockResolvedValue(null as any); + await sut.updateUser(authStub.user1, albumStub.sharedWithAdmin.id, userStub.admin.id, { role: AlbumUserRole.EDITOR, }); - expect(albumUserMock.update).toHaveBeenCalledWith( + expect(mocks.albumUser.update).toHaveBeenCalledWith( { albumsId: albumStub.sharedWithAdmin.id, usersId: userStub.admin.id }, { role: AlbumUserRole.EDITOR }, ); @@ -445,9 +439,9 @@ describe(AlbumService.name, () => { describe('getAlbumInfo', () => { it('should get a shared album', async () => { - albumMock.getById.mockResolvedValue(albumStub.oneAsset); - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id])); - albumMock.getMetadataForIds.mockResolvedValue([ + mocks.album.getById.mockResolvedValue(albumStub.oneAsset); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id])); + mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: albumStub.oneAsset.id, assetCount: 1, @@ -458,17 +452,17 @@ describe(AlbumService.name, () => { await sut.get(authStub.admin, albumStub.oneAsset.id, {}); - expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id, { withAssets: true }); - expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith( + expect(mocks.album.getById).toHaveBeenCalledWith(albumStub.oneAsset.id, { withAssets: true }); + expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([albumStub.oneAsset.id]), ); }); it('should get a shared album via a shared link', async () => { - albumMock.getById.mockResolvedValue(albumStub.oneAsset); - accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123'])); - albumMock.getMetadataForIds.mockResolvedValue([ + mocks.album.getById.mockResolvedValue(albumStub.oneAsset); + mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123'])); + mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: albumStub.oneAsset.id, assetCount: 1, @@ -479,17 +473,17 @@ describe(AlbumService.name, () => { await sut.get(authStub.adminSharedLink, 'album-123', {}); - expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true }); - expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalledWith( + expect(mocks.album.getById).toHaveBeenCalledWith('album-123', { withAssets: true }); + expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith( authStub.adminSharedLink.sharedLink?.id, new Set(['album-123']), ); }); it('should get a shared album via shared with user', async () => { - albumMock.getById.mockResolvedValue(albumStub.oneAsset); - accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123'])); - albumMock.getMetadataForIds.mockResolvedValue([ + mocks.album.getById.mockResolvedValue(albumStub.oneAsset); + mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123'])); + mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: albumStub.oneAsset.id, assetCount: 1, @@ -500,8 +494,8 @@ describe(AlbumService.name, () => { await sut.get(authStub.user1, 'album-123', {}); - expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true }); - expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith( + expect(mocks.album.getById).toHaveBeenCalledWith('album-123', { withAssets: true }); + expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalledWith( authStub.user1.user.id, new Set(['album-123']), AlbumUserRole.VIEWER, @@ -511,8 +505,8 @@ describe(AlbumService.name, () => { it('should throw an error for no access', async () => { await expect(sut.get(authStub.admin, 'album-123', {})).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-123'])); - expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith( + expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-123'])); + expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set(['album-123']), AlbumUserRole.VIEWER, @@ -522,10 +516,10 @@ describe(AlbumService.name, () => { describe('addAssets', () => { it('should allow the owner to add assets', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - albumMock.getAssetIds.mockResolvedValueOnce(new Set()); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect( sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), @@ -535,37 +529,37 @@ describe(AlbumService.name, () => { { success: true, id: 'asset-3' }, ]); - expect(albumMock.update).toHaveBeenCalledWith('album-123', { + expect(mocks.album.update).toHaveBeenCalledWith('album-123', { id: 'album-123', updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-1', }); - expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); }); it('should not set the thumbnail if the album has one already', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - albumMock.getById.mockResolvedValue(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' })); - albumMock.getAssetIds.mockResolvedValueOnce(new Set()); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' })); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([ { success: true, id: 'asset-1' }, ]); - expect(albumMock.update).toHaveBeenCalledWith('album-123', { + expect(mocks.album.update).toHaveBeenCalledWith('album-123', { id: 'album-123', updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-id', }); - expect(albumMock.addAssetIds).toHaveBeenCalled(); + expect(mocks.album.addAssetIds).toHaveBeenCalled(); }); it('should allow a shared user to add assets', async () => { - accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser)); - albumMock.getAssetIds.mockResolvedValueOnce(new Set()); + mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser)); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect( sut.addAssets(authStub.user1, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), @@ -575,34 +569,34 @@ describe(AlbumService.name, () => { { success: true, id: 'asset-3' }, ]); - expect(albumMock.update).toHaveBeenCalledWith('album-123', { + expect(mocks.album.update).toHaveBeenCalledWith('album-123', { id: 'album-123', updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-1', }); - expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); - expect(eventMock.emit).toHaveBeenCalledWith('album.update', { + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.event.emit).toHaveBeenCalledWith('album.update', { id: 'album-123', recipientIds: ['admin_id'], }); }); it('should not allow a shared user with viewer access to add assets', async () => { - accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set([])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser)); + mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser)); await expect( sut.addAssets(authStub.user2, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), ).rejects.toBeInstanceOf(BadRequestException); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should allow a shared link user to add assets', async () => { - accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - albumMock.getAssetIds.mockResolvedValueOnce(new Set()); + mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect( sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), @@ -612,115 +606,115 @@ describe(AlbumService.name, () => { { success: true, id: 'asset-3' }, ]); - expect(albumMock.update).toHaveBeenCalledWith('album-123', { + expect(mocks.album.update).toHaveBeenCalledWith('album-123', { id: 'album-123', updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-1', }); - expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); - expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalledWith( + expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith( authStub.adminSharedLink.sharedLink?.id, new Set(['album-123']), ); }); it('should allow adding assets shared via partner sharing', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.checkPartnerAccess.mockResolvedValue(new Set(['asset-1'])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - albumMock.getAssetIds.mockResolvedValueOnce(new Set()); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([ { success: true, id: 'asset-1' }, ]); - expect(albumMock.update).toHaveBeenCalledWith('album-123', { + expect(mocks.album.update).toHaveBeenCalledWith('album-123', { id: 'album-123', updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-1', }); - expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); }); it('should skip duplicate assets', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id'])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id'])); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set(['asset-id'])); await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ { success: false, id: 'asset-id', error: BulkIdErrorReason.DUPLICATE }, ]); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should skip assets not shared with user', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - albumMock.getById.mockResolvedValue(albumStub.oneAsset); - albumMock.getAssetIds.mockResolvedValueOnce(new Set()); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + mocks.album.getById.mockResolvedValue(albumStub.oneAsset); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([ { success: false, id: 'asset-1', error: BulkIdErrorReason.NO_PERMISSION }, ]); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); - expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); }); it('should not allow unauthorized access to the album', async () => { - albumMock.getById.mockResolvedValue(albumStub.oneAsset); + mocks.album.getById.mockResolvedValue(albumStub.oneAsset); await expect( sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), ).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.album.checkOwnerAccess).toHaveBeenCalled(); - expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalled(); + expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalled(); + expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalled(); }); it('should not allow unauthorized shared link access to the album', async () => { - albumMock.getById.mockResolvedValue(albumStub.oneAsset); + mocks.album.getById.mockResolvedValue(albumStub.oneAsset); await expect( sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), ).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalled(); + expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalled(); }); }); describe('removeAssets', () => { it('should allow the owner to remove assets', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id'])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - albumMock.getAssetIds.mockResolvedValue(new Set(['asset-id'])); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + mocks.album.getAssetIds.mockResolvedValue(new Set(['asset-id'])); await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ { success: true, id: 'asset-id' }, ]); - expect(albumMock.removeAssetIds).toHaveBeenCalledWith('album-123', ['asset-id']); + expect(mocks.album.removeAssetIds).toHaveBeenCalledWith('album-123', ['asset-id']); }); it('should skip assets not in the album', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.empty)); - albumMock.getAssetIds.mockResolvedValue(new Set()); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.empty)); + mocks.album.getAssetIds.mockResolvedValue(new Set()); await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ { success: false, id: 'asset-id', error: BulkIdErrorReason.NOT_FOUND }, ]); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should allow owner to remove all assets from the album', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - albumMock.getAssetIds.mockResolvedValue(new Set(['asset-id'])); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + mocks.album.getAssetIds.mockResolvedValue(new Set(['asset-id'])); await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ { success: true, id: 'asset-id' }, @@ -728,16 +722,16 @@ describe(AlbumService.name, () => { }); it('should reset the thumbnail if it is removed', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id'])); - albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.twoAssets)); - albumMock.getAssetIds.mockResolvedValue(new Set(['asset-id'])); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id'])); + mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.twoAssets)); + mocks.album.getAssetIds.mockResolvedValue(new Set(['asset-id'])); await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ { success: true, id: 'asset-id' }, ]); - expect(albumMock.updateThumbnails).toHaveBeenCalled(); + expect(mocks.album.updateThumbnails).toHaveBeenCalled(); }); }); diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index efc71c4c8d..722745ebd2 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -16,7 +16,7 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { AlbumUserEntity } from 'src/entities/album-user.entity'; import { AlbumEntity } from 'src/entities/album.entity'; import { Permission } from 'src/enum'; -import { AlbumAssetCount, AlbumInfoOptions } from 'src/interfaces/album.interface'; +import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository'; import { BaseService } from 'src/services/base.service'; import { addAssets, removeAssets } from 'src/utils/asset.util'; @@ -55,13 +55,7 @@ export class AlbumService extends BaseService { const results = await this.albumRepository.getMetadataForIds(albums.map((album) => album.id)); const albumMetadata: Record = {}; for (const metadata of results) { - const { albumId, assetCount, startDate, endDate } = metadata; - albumMetadata[albumId] = { - albumId, - assetCount, - startDate, - endDate, - }; + albumMetadata[metadata.albumId] = metadata; } return Promise.all( @@ -70,9 +64,9 @@ export class AlbumService extends BaseService { return { ...mapAlbumWithoutAssets(album), sharedLinks: undefined, - startDate: albumMetadata[album.id].startDate, - endDate: albumMetadata[album.id].endDate, - assetCount: albumMetadata[album.id].assetCount, + startDate: albumMetadata[album.id]?.startDate ?? undefined, + endDate: albumMetadata[album.id]?.endDate ?? undefined, + assetCount: albumMetadata[album.id]?.assetCount ?? 0, lastModifiedAssetTimestamp: lastModifiedAsset?.updatedAt, }; }), @@ -89,9 +83,9 @@ export class AlbumService extends BaseService { return { ...mapAlbum(album, withAssets, auth), - startDate: albumMetadataForIds.startDate, - endDate: albumMetadataForIds.endDate, - assetCount: albumMetadataForIds.assetCount, + startDate: albumMetadataForIds?.startDate ?? undefined, + endDate: albumMetadataForIds?.endDate ?? undefined, + assetCount: albumMetadataForIds?.assetCount ?? 0, lastModifiedAssetTimestamp: lastModifiedAsset?.updatedAt, }; } diff --git a/server/src/services/api-key.service.spec.ts b/server/src/services/api-key.service.spec.ts index 928978b698..0a89c04b0d 100644 --- a/server/src/services/api-key.service.spec.ts +++ b/server/src/services/api-key.service.spec.ts @@ -1,117 +1,152 @@ import { BadRequestException } from '@nestjs/common'; import { Permission } from 'src/enum'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { APIKeyService } from 'src/services/api-key.service'; -import { IApiKeyRepository } from 'src/types'; -import { keyStub } from 'test/fixtures/api-key.stub'; -import { authStub } from 'test/fixtures/auth.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { ApiKeyService } from 'src/services/api-key.service'; +import { factory, newUuid } from 'test/small.factory'; +import { newTestService, ServiceMocks } from 'test/utils'; -describe(APIKeyService.name, () => { - let sut: APIKeyService; - - let cryptoMock: Mocked; - let keyMock: Mocked; +describe(ApiKeyService.name, () => { + let sut: ApiKeyService; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, cryptoMock, keyMock } = newTestService(APIKeyService)); + ({ sut, mocks } = newTestService(ApiKeyService)); }); describe('create', () => { it('should create a new key', async () => { - keyMock.create.mockResolvedValue(keyStub.admin); - await sut.create(authStub.admin, { name: 'Test Key', permissions: [Permission.ALL] }); - expect(keyMock.create).toHaveBeenCalledWith({ - key: 'cmFuZG9tLWJ5dGVz (hashed)', - name: 'Test Key', - permissions: [Permission.ALL], - userId: authStub.admin.user.id, + const auth = factory.auth(); + const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.ALL] }); + const key = 'super-secret'; + + mocks.crypto.newPassword.mockReturnValue(key); + mocks.apiKey.create.mockResolvedValue(apiKey); + + await sut.create(auth, { name: apiKey.name, permissions: apiKey.permissions }); + + expect(mocks.apiKey.create).toHaveBeenCalledWith({ + key: 'super-secret (hashed)', + name: apiKey.name, + permissions: apiKey.permissions, + userId: apiKey.userId, }); - expect(cryptoMock.newPassword).toHaveBeenCalled(); - expect(cryptoMock.hashSha256).toHaveBeenCalled(); + expect(mocks.crypto.newPassword).toHaveBeenCalled(); + expect(mocks.crypto.hashSha256).toHaveBeenCalled(); }); it('should not require a name', async () => { - keyMock.create.mockResolvedValue(keyStub.admin); + const auth = factory.auth(); + const apiKey = factory.apiKey({ userId: auth.user.id }); + const key = 'super-secret'; - await sut.create(authStub.admin, { permissions: [Permission.ALL] }); + mocks.crypto.newPassword.mockReturnValue(key); + mocks.apiKey.create.mockResolvedValue(apiKey); - expect(keyMock.create).toHaveBeenCalledWith({ - key: 'cmFuZG9tLWJ5dGVz (hashed)', + await sut.create(auth, { permissions: [Permission.ALL] }); + + expect(mocks.apiKey.create).toHaveBeenCalledWith({ + key: 'super-secret (hashed)', name: 'API Key', permissions: [Permission.ALL], - userId: authStub.admin.user.id, + userId: auth.user.id, }); - expect(cryptoMock.newPassword).toHaveBeenCalled(); - expect(cryptoMock.hashSha256).toHaveBeenCalled(); + expect(mocks.crypto.newPassword).toHaveBeenCalled(); + expect(mocks.crypto.hashSha256).toHaveBeenCalled(); }); it('should throw an error if the api key does not have sufficient permissions', async () => { - await expect( - sut.create({ ...authStub.admin, apiKey: keyStub.authKey }, { permissions: [Permission.ASSET_READ] }), - ).rejects.toBeInstanceOf(BadRequestException); + const auth = factory.auth({ apiKey: factory.authApiKey({ permissions: [Permission.ASSET_READ] }) }); + + await expect(sut.create(auth, { permissions: [Permission.ASSET_UPDATE] })).rejects.toBeInstanceOf( + BadRequestException, + ); }); }); describe('update', () => { it('should throw an error if the key is not found', async () => { - await expect(sut.update(authStub.admin, 'random-guid', { name: 'New Name' })).rejects.toBeInstanceOf( - BadRequestException, - ); + const id = newUuid(); + const auth = factory.auth(); - expect(keyMock.update).not.toHaveBeenCalledWith('random-guid'); + mocks.apiKey.getById.mockResolvedValue(void 0); + + await expect(sut.update(auth, id, { name: 'New Name' })).rejects.toBeInstanceOf(BadRequestException); + + expect(mocks.apiKey.update).not.toHaveBeenCalledWith(id); }); it('should update a key', async () => { - keyMock.getById.mockResolvedValue(keyStub.admin); - keyMock.update.mockResolvedValue(keyStub.admin); + const auth = factory.auth(); + const apiKey = factory.apiKey({ userId: auth.user.id }); + const newName = 'New name'; - await sut.update(authStub.admin, 'random-guid', { name: 'New Name' }); + mocks.apiKey.getById.mockResolvedValue(apiKey); + mocks.apiKey.update.mockResolvedValue(apiKey); - expect(keyMock.update).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid', { name: 'New Name' }); + await sut.update(auth, apiKey.id, { name: newName }); + + expect(mocks.apiKey.update).toHaveBeenCalledWith(auth.user.id, apiKey.id, { name: newName }); }); }); describe('delete', () => { it('should throw an error if the key is not found', async () => { - await expect(sut.delete(authStub.admin, 'random-guid')).rejects.toBeInstanceOf(BadRequestException); + const auth = factory.auth(); + const id = newUuid(); - expect(keyMock.delete).not.toHaveBeenCalledWith('random-guid'); + mocks.apiKey.getById.mockResolvedValue(void 0); + + await expect(sut.delete(auth, id)).rejects.toBeInstanceOf(BadRequestException); + + expect(mocks.apiKey.delete).not.toHaveBeenCalledWith(id); }); it('should delete a key', async () => { - keyMock.getById.mockResolvedValue(keyStub.admin); + const auth = factory.auth(); + const apiKey = factory.apiKey({ userId: auth.user.id }); - await sut.delete(authStub.admin, 'random-guid'); + mocks.apiKey.getById.mockResolvedValue(apiKey); + mocks.apiKey.delete.mockResolvedValue(); - expect(keyMock.delete).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid'); + await sut.delete(auth, apiKey.id); + + expect(mocks.apiKey.delete).toHaveBeenCalledWith(auth.user.id, apiKey.id); }); }); describe('getById', () => { it('should throw an error if the key is not found', async () => { - await expect(sut.getById(authStub.admin, 'random-guid')).rejects.toBeInstanceOf(BadRequestException); + const auth = factory.auth(); + const id = newUuid(); - expect(keyMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid'); + mocks.apiKey.getById.mockResolvedValue(void 0); + + await expect(sut.getById(auth, id)).rejects.toBeInstanceOf(BadRequestException); + + expect(mocks.apiKey.getById).toHaveBeenCalledWith(auth.user.id, id); }); it('should get a key by id', async () => { - keyMock.getById.mockResolvedValue(keyStub.admin); + const auth = factory.auth(); + const apiKey = factory.apiKey({ userId: auth.user.id }); - await sut.getById(authStub.admin, 'random-guid'); + mocks.apiKey.getById.mockResolvedValue(apiKey); - expect(keyMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid'); + await sut.getById(auth, apiKey.id); + + expect(mocks.apiKey.getById).toHaveBeenCalledWith(auth.user.id, apiKey.id); }); }); describe('getAll', () => { it('should return all the keys for a user', async () => { - keyMock.getByUserId.mockResolvedValue([keyStub.admin]); + const auth = factory.auth(); + const apiKey = factory.apiKey({ userId: auth.user.id }); - await expect(sut.getAll(authStub.admin)).resolves.toHaveLength(1); + mocks.apiKey.getByUserId.mockResolvedValue([apiKey]); - expect(keyMock.getByUserId).toHaveBeenCalledWith(authStub.admin.user.id); + await expect(sut.getAll(auth)).resolves.toHaveLength(1); + + expect(mocks.apiKey.getByUserId).toHaveBeenCalledWith(auth.user.id); }); }); }); diff --git a/server/src/services/api-key.service.ts b/server/src/services/api-key.service.ts index 7d9a4f3776..5459b56889 100644 --- a/server/src/services/api-key.service.ts +++ b/server/src/services/api-key.service.ts @@ -7,7 +7,7 @@ import { ApiKeyItem } from 'src/types'; import { isGranted } from 'src/utils/access'; @Injectable() -export class APIKeyService extends BaseService { +export class ApiKeyService extends BaseService { async create(auth: AuthDto, dto: APIKeyCreateDto): Promise { const secret = this.cryptoRepository.newPassword(32); @@ -15,7 +15,7 @@ export class APIKeyService extends BaseService { throw new BadRequestException('Cannot grant permissions you do not have'); } - const entity = await this.keyRepository.create({ + const entity = await this.apiKeyRepository.create({ key: this.cryptoRepository.hashSha256(secret), name: dto.name || 'API Key', userId: auth.user.id, @@ -26,27 +26,27 @@ export class APIKeyService extends BaseService { } async update(auth: AuthDto, id: string, dto: APIKeyUpdateDto): Promise { - const exists = await this.keyRepository.getById(auth.user.id, id); + const exists = await this.apiKeyRepository.getById(auth.user.id, id); if (!exists) { throw new BadRequestException('API Key not found'); } - const key = await this.keyRepository.update(auth.user.id, id, { name: dto.name }); + const key = await this.apiKeyRepository.update(auth.user.id, id, { name: dto.name }); return this.map(key); } async delete(auth: AuthDto, id: string): Promise { - const exists = await this.keyRepository.getById(auth.user.id, id); + const exists = await this.apiKeyRepository.getById(auth.user.id, id); if (!exists) { throw new BadRequestException('API Key not found'); } - await this.keyRepository.delete(auth.user.id, id); + await this.apiKeyRepository.delete(auth.user.id, id); } async getById(auth: AuthDto, id: string): Promise { - const key = await this.keyRepository.getById(auth.user.id, id); + const key = await this.apiKeyRepository.getById(auth.user.id, id); if (!key) { throw new BadRequestException('API Key not found'); } @@ -54,7 +54,7 @@ export class APIKeyService extends BaseService { } async getAll(auth: AuthDto): Promise { - const keys = await this.keyRepository.getByUserId(auth.user.id); + const keys = await this.apiKeyRepository.getByUserId(auth.user.id); return keys.map((key) => this.map(key)); } diff --git a/server/src/services/api.service.ts b/server/src/services/api.service.ts index a18f863f99..71fb36b4f2 100644 --- a/server/src/services/api.service.ts +++ b/server/src/services/api.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { Cron, CronExpression, Interval } from '@nestjs/schedule'; import { NextFunction, Request, Response } from 'express'; import { readFileSync } from 'node:fs'; +import sanitizeHtml from 'sanitize-html'; import { ONE_HOUR } from 'src/constants'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; @@ -12,21 +13,25 @@ import { VersionService } from 'src/services/version.service'; import { OpenGraphTags } from 'src/utils/misc'; const render = (index: string, meta: OpenGraphTags) => { + const [title, description, imageUrl] = [meta.title, meta.description, meta.imageUrl].map((item) => + item ? sanitizeHtml(item, { allowedTags: [] }) : '', + ); + const tags = ` - + - - - ${meta.imageUrl ? `` : ''} + + + ${imageUrl ? `` : ''} - - + + - ${meta.imageUrl ? `` : ''}`; + ${imageUrl ? `` : ''}`; return index.replace('', tags); }; diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 9dcfa3cbd9..97736b905c 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -9,11 +9,7 @@ import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto'; import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity'; -import { AssetFileType, AssetStatus, AssetType, CacheControl } from 'src/enum'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IJobRepository, JobName } from 'src/interfaces/job.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { AssetFileType, AssetStatus, AssetType, CacheControl, JobName } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; import { AssetMediaService } from 'src/services/asset-media.service'; import { ImmichFileResponse } from 'src/utils/file'; @@ -21,9 +17,7 @@ import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex'); @@ -73,6 +67,7 @@ const validImages = [ '.heic', '.heif', '.iiq', + '.jp2', '.jpeg', '.jpg', '.jxl', @@ -202,15 +197,10 @@ const copiedAsset = Object.freeze({ describe(AssetMediaService.name, () => { let sut: AssetMediaService; - - let accessMock: IAccessRepositoryMock; - let assetMock: Mocked; - let jobMock: Mocked; - let storageMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, assetMock, jobMock, storageMock, userMock } = newTestService(AssetMediaService)); + ({ sut, mocks } = newTestService(AssetMediaService)); }); describe('getUploadAssetIdByChecksum', () => { @@ -220,25 +210,25 @@ describe(AssetMediaService.name, () => { it('should handle a non-existent asset', async () => { await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toBeUndefined(); - expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); + expect(mocks.asset.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); }); it('should find an existing asset', async () => { - assetMock.getUploadAssetIdByChecksum.mockResolvedValue('asset-id'); + mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('asset-id'); await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toEqual({ id: 'asset-id', status: AssetMediaStatus.DUPLICATE, }); - expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); + expect(mocks.asset.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); }); it('should find an existing asset by base64', async () => { - assetMock.getUploadAssetIdByChecksum.mockResolvedValue('asset-id'); + mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('asset-id'); await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('base64'))).resolves.toEqual({ id: 'asset-id', status: AssetMediaStatus.DUPLICATE, }); - expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); + expect(mocks.asset.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); }); }); @@ -307,14 +297,14 @@ describe(AssetMediaService.name, () => { expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual( 'upload/profile/admin_id', ); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile/admin_id'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/profile/admin_id'); }); it('should return upload for everything else', () => { expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual( 'upload/upload/admin_id/ra/nd', ); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/upload/admin_id/ra/nd'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/upload/admin_id/ra/nd'); }); }); @@ -329,7 +319,7 @@ describe(AssetMediaService.name, () => { size: 42, }; - assetMock.create.mockResolvedValue(assetEntity); + mocks.asset.create.mockResolvedValue(assetEntity); await expect( sut.uploadAsset( @@ -339,9 +329,9 @@ describe(AssetMediaService.name, () => { ), ).rejects.toBeInstanceOf(BadRequestException); - expect(assetMock.create).not.toHaveBeenCalled(); - expect(userMock.updateUsage).not.toHaveBeenCalledWith(authStub.user1.user.id, file.size); - expect(storageMock.utimes).not.toHaveBeenCalledWith( + expect(mocks.asset.create).not.toHaveBeenCalled(); + expect(mocks.user.updateUsage).not.toHaveBeenCalledWith(authStub.user1.user.id, file.size); + expect(mocks.storage.utimes).not.toHaveBeenCalledWith( file.originalPath, expect.any(Date), new Date(createDto.fileModifiedAt), @@ -358,16 +348,16 @@ describe(AssetMediaService.name, () => { size: 42, }; - assetMock.create.mockResolvedValue(assetEntity); + mocks.asset.create.mockResolvedValue(assetEntity); await expect(sut.uploadAsset(authStub.user1, createDto, file)).resolves.toEqual({ id: 'id_1', status: AssetMediaStatus.CREATED, }); - expect(assetMock.create).toHaveBeenCalled(); - expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, file.size); - expect(storageMock.utimes).toHaveBeenCalledWith( + expect(mocks.asset.create).toHaveBeenCalled(); + expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, file.size); + expect(mocks.storage.utimes).toHaveBeenCalledWith( file.originalPath, expect.any(Date), new Date(createDto.fileModifiedAt), @@ -386,19 +376,19 @@ describe(AssetMediaService.name, () => { const error = new Error('unique key violation'); (error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT; - assetMock.create.mockRejectedValue(error); - assetMock.getUploadAssetIdByChecksum.mockResolvedValue(assetEntity.id); + mocks.asset.create.mockRejectedValue(error); + mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue(assetEntity.id); await expect(sut.uploadAsset(authStub.user1, createDto, file)).resolves.toEqual({ id: 'id_1', status: AssetMediaStatus.DUPLICATE, }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.DELETE_FILES, data: { files: ['fake_path/asset_1.jpeg', undefined] }, }); - expect(userMock.updateUsage).not.toHaveBeenCalled(); + expect(mocks.user.updateUsage).not.toHaveBeenCalled(); }); it('should throw an error if the duplicate could not be found by checksum', async () => { @@ -413,22 +403,22 @@ describe(AssetMediaService.name, () => { const error = new Error('unique key violation'); (error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT; - assetMock.create.mockRejectedValue(error); + mocks.asset.create.mockRejectedValue(error); await expect(sut.uploadAsset(authStub.user1, createDto, file)).rejects.toBeInstanceOf( InternalServerErrorException, ); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.DELETE_FILES, data: { files: ['fake_path/asset_1.jpeg', undefined] }, }); - expect(userMock.updateUsage).not.toHaveBeenCalled(); + expect(mocks.user.updateUsage).not.toHaveBeenCalled(); }); it('should handle a live photo', async () => { - assetMock.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); - assetMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); + mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); + mocks.asset.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); await expect( sut.uploadAsset( @@ -441,13 +431,13 @@ describe(AssetMediaService.name, () => { id: 'live-photo-still-asset', }); - expect(assetMock.getById).toHaveBeenCalledWith('live-photo-motion-asset'); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.getById).toHaveBeenCalledWith('live-photo-motion-asset'); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should hide the linked motion asset', async () => { - assetMock.getById.mockResolvedValueOnce({ ...assetStub.livePhotoMotionAsset, isVisible: true }); - assetMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); + mocks.asset.getById.mockResolvedValueOnce({ ...assetStub.livePhotoMotionAsset, isVisible: true }); + mocks.asset.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); await expect( sut.uploadAsset( @@ -460,25 +450,25 @@ describe(AssetMediaService.name, () => { id: 'live-photo-still-asset', }); - expect(assetMock.getById).toHaveBeenCalledWith('live-photo-motion-asset'); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'live-photo-motion-asset', isVisible: false }); + expect(mocks.asset.getById).toHaveBeenCalledWith('live-photo-motion-asset'); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'live-photo-motion-asset', isVisible: false }); }); it('should handle a sidecar file', async () => { - assetMock.getById.mockResolvedValueOnce(assetStub.image); - assetMock.create.mockResolvedValueOnce(assetStub.image); + mocks.asset.getById.mockResolvedValueOnce(assetStub.image); + mocks.asset.create.mockResolvedValueOnce(assetStub.image); await expect(sut.uploadAsset(authStub.user1, createDto, fileStub.photo, fileStub.photoSidecar)).resolves.toEqual({ status: AssetMediaStatus.CREATED, id: assetStub.image.id, }); - expect(storageMock.utimes).toHaveBeenCalledWith( + expect(mocks.storage.utimes).toHaveBeenCalledWith( fileStub.photoSidecar.originalPath, expect.any(Date), new Date(createDto.fileModifiedAt), ); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); }); @@ -486,22 +476,22 @@ describe(AssetMediaService.name, () => { it('should require the asset.download permission', async () => { await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); - expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); - expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); }); it('should throw an error if the asset is not found', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(NotFoundException); - expect(assetMock.getById).toHaveBeenCalledWith('asset-1', { files: true }); + expect(mocks.asset.getById).toHaveBeenCalledWith('asset-1', { files: true }); }); it('should download a file', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.asset.getById.mockResolvedValue(assetStub.image); await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).resolves.toEqual( new ImmichFileResponse({ @@ -517,13 +507,13 @@ describe(AssetMediaService.name, () => { it('should require asset.view permissions', async () => { await expect(sut.viewThumbnail(authStub.admin, 'id', {})).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); - expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); - expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); }); it('should throw an error if the asset does not exist', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); await expect( sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }), @@ -531,8 +521,8 @@ describe(AssetMediaService.name, () => { }); it('should throw an error if the requested thumbnail file does not exist', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue({ ...assetStub.image, files: [] }); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue({ ...assetStub.image, files: [] }); await expect( sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }), @@ -540,8 +530,8 @@ describe(AssetMediaService.name, () => { }); it('should throw an error if the requested preview file does not exist', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue({ + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue({ ...assetStub.image, files: [ { @@ -560,8 +550,8 @@ describe(AssetMediaService.name, () => { }); it('should fall back to preview if the requested thumbnail file does not exist', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue({ + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue({ ...assetStub.image, files: [ { @@ -588,8 +578,8 @@ describe(AssetMediaService.name, () => { }); it('should get preview file', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue({ ...assetStub.image }); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue({ ...assetStub.image }); await expect( sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }), ).resolves.toEqual( @@ -603,8 +593,8 @@ describe(AssetMediaService.name, () => { }); it('should get thumbnail file', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue({ ...assetStub.image }); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue({ ...assetStub.image }); await expect( sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }), ).resolves.toEqual( @@ -622,27 +612,27 @@ describe(AssetMediaService.name, () => { it('should require asset.view permissions', async () => { await expect(sut.playbackVideo(authStub.admin, 'id')).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); - expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); - expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); }); it('should throw an error if the asset does not exist', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); await expect(sut.playbackVideo(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(NotFoundException); }); it('should throw an error if the asset is not a video', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue(assetStub.image); await expect(sut.playbackVideo(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException); }); it('should return the encoded video path if available', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.hasEncodedVideo.id])); - assetMock.getById.mockResolvedValue(assetStub.hasEncodedVideo); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.hasEncodedVideo.id])); + mocks.asset.getById.mockResolvedValue(assetStub.hasEncodedVideo); await expect(sut.playbackVideo(authStub.admin, assetStub.hasEncodedVideo.id)).resolves.toEqual( new ImmichFileResponse({ @@ -654,8 +644,8 @@ describe(AssetMediaService.name, () => { }); it('should fall back to the original path', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.video.id])); - assetMock.getById.mockResolvedValue(assetStub.video); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.video.id])); + mocks.asset.getById.mockResolvedValue(assetStub.video); await expect(sut.playbackVideo(authStub.admin, assetStub.video.id)).resolves.toEqual( new ImmichFileResponse({ @@ -669,12 +659,12 @@ describe(AssetMediaService.name, () => { describe('checkExistingAssets', () => { it('should get existing asset ids', async () => { - assetMock.getByDeviceIds.mockResolvedValue(['42']); + mocks.asset.getByDeviceIds.mockResolvedValue(['42']); await expect( sut.checkExistingAssets(authStub.admin, { deviceId: '420', deviceAssetIds: ['69'] }), ).resolves.toEqual({ existingIds: ['42'] }); - expect(assetMock.getByDeviceIds).toHaveBeenCalledWith(userStub.admin.id, '420', ['69']); + expect(mocks.asset.getByDeviceIds).toHaveBeenCalledWith(userStub.admin.id, '420', ['69']); }); }); @@ -684,26 +674,26 @@ describe(AssetMediaService.name, () => { 'Not found or no asset.update access', ); - expect(assetMock.create).not.toHaveBeenCalled(); + expect(mocks.asset.create).not.toHaveBeenCalled(); }); it('should update a photo with no sidecar to photo with no sidecar', async () => { const updatedFile = fileStub.photo; const updatedAsset = { ...existingAsset, ...updatedFile }; - assetMock.getById.mockResolvedValueOnce(existingAsset); - assetMock.getById.mockResolvedValueOnce(updatedAsset); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id])); + mocks.asset.getById.mockResolvedValueOnce(existingAsset); + mocks.asset.getById.mockResolvedValueOnce(updatedAsset); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id])); // this is the original file size - storageMock.stat.mockResolvedValue({ size: 0 } as Stats); + mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats); // this is for the clone call - assetMock.create.mockResolvedValue(copiedAsset); + mocks.asset.create.mockResolvedValue(copiedAsset); await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({ status: AssetMediaStatus.REPLACED, id: 'copied-asset', }); - expect(assetMock.update).toHaveBeenCalledWith( + expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ id: existingAsset.id, sidecarPath: null, @@ -711,7 +701,7 @@ describe(AssetMediaService.name, () => { originalPath: 'fake_path/photo1.jpeg', }), ); - expect(assetMock.create).toHaveBeenCalledWith( + expect(mocks.asset.create).toHaveBeenCalledWith( expect.objectContaining({ sidecarPath: null, originalFileName: 'existing-filename.jpeg', @@ -719,12 +709,12 @@ describe(AssetMediaService.name, () => { }), ); - expect(assetMock.updateAll).toHaveBeenCalledWith([copiedAsset.id], { + expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], { deletedAt: expect.any(Date), status: AssetStatus.TRASHED, }); - expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); - expect(storageMock.utimes).toHaveBeenCalledWith( + expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); + expect(mocks.storage.utimes).toHaveBeenCalledWith( updatedFile.originalPath, expect.any(Date), new Date(replaceDto.fileModifiedAt), @@ -735,13 +725,13 @@ describe(AssetMediaService.name, () => { const updatedFile = fileStub.photo; const sidecarFile = fileStub.photoSidecar; const updatedAsset = { ...sidecarAsset, ...updatedFile }; - assetMock.getById.mockResolvedValueOnce(existingAsset); - assetMock.getById.mockResolvedValueOnce(updatedAsset); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); + mocks.asset.getById.mockResolvedValueOnce(existingAsset); + mocks.asset.getById.mockResolvedValueOnce(updatedAsset); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); // this is the original file size - storageMock.stat.mockResolvedValue({ size: 0 } as Stats); + mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats); // this is for the clone call - assetMock.create.mockResolvedValue(copiedAsset); + mocks.asset.create.mockResolvedValue(copiedAsset); await expect( sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile, sidecarFile), @@ -750,12 +740,12 @@ describe(AssetMediaService.name, () => { id: 'copied-asset', }); - expect(assetMock.updateAll).toHaveBeenCalledWith([copiedAsset.id], { + expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], { deletedAt: expect.any(Date), status: AssetStatus.TRASHED, }); - expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); - expect(storageMock.utimes).toHaveBeenCalledWith( + expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); + expect(mocks.storage.utimes).toHaveBeenCalledWith( updatedFile.originalPath, expect.any(Date), new Date(replaceDto.fileModifiedAt), @@ -766,25 +756,25 @@ describe(AssetMediaService.name, () => { const updatedFile = fileStub.photo; const updatedAsset = { ...sidecarAsset, ...updatedFile }; - assetMock.getById.mockResolvedValueOnce(sidecarAsset); - assetMock.getById.mockResolvedValueOnce(updatedAsset); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); + mocks.asset.getById.mockResolvedValueOnce(sidecarAsset); + mocks.asset.getById.mockResolvedValueOnce(updatedAsset); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); // this is the original file size - storageMock.stat.mockResolvedValue({ size: 0 } as Stats); + mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats); // this is for the copy call - assetMock.create.mockResolvedValue(copiedAsset); + mocks.asset.create.mockResolvedValue(copiedAsset); await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({ status: AssetMediaStatus.REPLACED, id: 'copied-asset', }); - expect(assetMock.updateAll).toHaveBeenCalledWith([copiedAsset.id], { + expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], { deletedAt: expect.any(Date), status: AssetStatus.TRASHED, }); - expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); - expect(storageMock.utimes).toHaveBeenCalledWith( + expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); + expect(mocks.storage.utimes).toHaveBeenCalledWith( updatedFile.originalPath, expect.any(Date), new Date(replaceDto.fileModifiedAt), @@ -796,27 +786,27 @@ describe(AssetMediaService.name, () => { const error = new Error('unique key violation'); (error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT; - assetMock.update.mockRejectedValue(error); - assetMock.getById.mockResolvedValueOnce(sidecarAsset); - assetMock.getUploadAssetIdByChecksum.mockResolvedValue(sidecarAsset.id); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); + mocks.asset.update.mockRejectedValue(error); + mocks.asset.getById.mockResolvedValueOnce(sidecarAsset); + mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue(sidecarAsset.id); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); // this is the original file size - storageMock.stat.mockResolvedValue({ size: 0 } as Stats); + mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats); // this is for the clone call - assetMock.create.mockResolvedValue(copiedAsset); + mocks.asset.create.mockResolvedValue(copiedAsset); await expect(sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile)).resolves.toEqual({ status: AssetMediaStatus.DUPLICATE, id: sidecarAsset.id, }); - expect(assetMock.create).not.toHaveBeenCalled(); - expect(assetMock.updateAll).not.toHaveBeenCalled(); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.asset.create).not.toHaveBeenCalled(); + expect(mocks.asset.updateAll).not.toHaveBeenCalled(); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.DELETE_FILES, data: { files: [updatedFile.originalPath, undefined] }, }); - expect(userMock.updateUsage).not.toHaveBeenCalled(); + expect(mocks.user.updateUsage).not.toHaveBeenCalled(); }); }); @@ -825,7 +815,7 @@ describe(AssetMediaService.name, () => { const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex'); const file2 = Buffer.from('53be335e99f18a66ff12e9a901c7a6171dd76573', 'hex'); - assetMock.getByChecksums.mockResolvedValue([ + mocks.asset.getByChecksums.mockResolvedValue([ { id: 'asset-1', checksum: file1 } as AssetEntity, { id: 'asset-2', checksum: file2 } as AssetEntity, ]); @@ -856,14 +846,14 @@ describe(AssetMediaService.name, () => { ], }); - expect(assetMock.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]); + expect(mocks.asset.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]); }); it('should return non-duplicates as well', async () => { const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex'); const file2 = Buffer.from('53be335e99f18a66ff12e9a901c7a6171dd76573', 'hex'); - assetMock.getByChecksums.mockResolvedValue([{ id: 'asset-1', checksum: file1 } as AssetEntity]); + mocks.asset.getByChecksums.mockResolvedValue([{ id: 'asset-1', checksum: file1 } as AssetEntity]); await expect( sut.bulkUploadCheck(authStub.admin, { @@ -888,7 +878,7 @@ describe(AssetMediaService.name, () => { ], }); - expect(assetMock.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]); + expect(mocks.asset.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]); }); }); @@ -909,7 +899,7 @@ describe(AssetMediaService.name, () => { await sut.onUploadError(request, file); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.DELETE_FILES, data: { files: ['upload/upload/user-id/ra/nd/random-uuid.jpg'] }, }); diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index fab836db94..09ebd9db71 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -21,29 +21,22 @@ import { } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity'; -import { AssetStatus, AssetType, CacheControl, Permission, StorageFolder } from 'src/enum'; -import { JobName } from 'src/interfaces/job.interface'; +import { AssetStatus, AssetType, CacheControl, JobName, Permission, StorageFolder } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; import { BaseService } from 'src/services/base.service'; +import { UploadFile } from 'src/types'; import { requireUploadAccess } from 'src/utils/access'; import { asRequest, getAssetFiles, onBeforeLink } from 'src/utils/asset.util'; import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; import { fromChecksum } from 'src/utils/request'; -export interface UploadRequest { + +interface UploadRequest { auth: AuthDto | null; fieldName: UploadFieldName; file: UploadFile; } -export interface UploadFile { - uuid: string; - checksum: Buffer; - originalPath: string; - originalName: string; - size: number; -} - @Injectable() export class AssetMediaService extends BaseService { async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise { diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index bf36c181fc..ca3490a1c0 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -3,23 +3,17 @@ import { DateTime } from 'luxon'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto'; import { AssetEntity } from 'src/entities/asset.entity'; -import { AssetStatus, AssetType } from 'src/enum'; -import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; -import { IStackRepository } from 'src/interfaces/stack.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { AssetStatus, AssetType, JobName, JobStatus } from 'src/enum'; +import { AssetStats } from 'src/repositories/asset.repository'; import { AssetService } from 'src/services/asset.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { partnerStub } from 'test/fixtures/partner.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked, vitest } from 'vitest'; +import { factory } from 'test/small.factory'; +import { makeStream, newTestService, ServiceMocks } from 'test/utils'; +import { vitest } from 'vitest'; const stats: AssetStats = { [AssetType.IMAGE]: 10, @@ -36,27 +30,18 @@ const statResponse: AssetStatsResponseDto = { describe(AssetService.name, () => { let sut: AssetService; - - let accessMock: IAccessRepositoryMock; - let assetMock: Mocked; - let eventMock: Mocked; - let jobMock: Mocked; - let partnerMock: Mocked; - let stackMock: Mocked; - let systemMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; it('should work', () => { expect(sut).toBeDefined(); }); const mockGetById = (assets: AssetEntity[]) => { - assetMock.getById.mockImplementation((assetId) => Promise.resolve(assets.find((asset) => asset.id === assetId))); + mocks.asset.getById.mockImplementation((assetId) => Promise.resolve(assets.find((asset) => asset.id === assetId))); }; beforeEach(() => { - ({ sut, accessMock, assetMock, eventMock, jobMock, partnerMock, stackMock, systemMock, userMock } = - newTestService(AssetService)); + ({ sut, mocks } = newTestService(AssetService)); mockGetById([assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]); }); @@ -77,21 +62,21 @@ describe(AssetService.name, () => { const image3 = { ...assetStub.image, localDateTime: new Date(2015, 1, 15) }; const image4 = { ...assetStub.image, localDateTime: new Date(2009, 1, 15) }; - partnerMock.getAll.mockResolvedValue([]); - assetMock.getByDayOfYear.mockResolvedValue([ + mocks.partner.getAll.mockResolvedValue([]); + mocks.asset.getByDayOfYear.mockResolvedValue([ { - yearsAgo: 1, + year: 2023, assets: [image1, image2], }, { - yearsAgo: 9, + year: 2015, assets: [image3], }, { - yearsAgo: 15, + year: 2009, assets: [image4], }, - ]); + ] as any); await expect(sut.getMemoryLane(authStub.admin, { day: 15, month: 1 })).resolves.toEqual([ { yearsAgo: 1, title: '1 year ago', assets: [mapAsset(image1), mapAsset(image2)] }, @@ -99,16 +84,16 @@ describe(AssetService.name, () => { { yearsAgo: 15, title: '15 years ago', assets: [mapAsset(image4)] }, ]); - expect(assetMock.getByDayOfYear.mock.calls).toEqual([[[authStub.admin.user.id], { day: 15, month: 1 }]]); + expect(mocks.asset.getByDayOfYear.mock.calls).toEqual([[[authStub.admin.user.id], { day: 15, month: 1 }]]); }); it('should get memories with partners with inTimeline enabled', async () => { - partnerMock.getAll.mockResolvedValue([partnerStub.user1ToAdmin1]); - assetMock.getByDayOfYear.mockResolvedValue([]); + mocks.partner.getAll.mockResolvedValue([partnerStub.user1ToAdmin1]); + mocks.asset.getByDayOfYear.mockResolvedValue([]); await sut.getMemoryLane(authStub.admin, { day: 15, month: 1 }); - expect(assetMock.getByDayOfYear.mock.calls).toEqual([ + expect(mocks.asset.getByDayOfYear.mock.calls).toEqual([ [[authStub.admin.user.id, userStub.user1.id], { day: 15, month: 1 }], ]); }); @@ -116,76 +101,79 @@ describe(AssetService.name, () => { describe('getStatistics', () => { it('should get the statistics for a user, excluding archived assets', async () => { - assetMock.getStatistics.mockResolvedValue(stats); + mocks.asset.getStatistics.mockResolvedValue(stats); await expect(sut.getStatistics(authStub.admin, { isArchived: false })).resolves.toEqual(statResponse); - expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isArchived: false }); + expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isArchived: false }); }); it('should get the statistics for a user for archived assets', async () => { - assetMock.getStatistics.mockResolvedValue(stats); + mocks.asset.getStatistics.mockResolvedValue(stats); await expect(sut.getStatistics(authStub.admin, { isArchived: true })).resolves.toEqual(statResponse); - expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isArchived: true }); + expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isArchived: true }); }); it('should get the statistics for a user for favorite assets', async () => { - assetMock.getStatistics.mockResolvedValue(stats); + mocks.asset.getStatistics.mockResolvedValue(stats); await expect(sut.getStatistics(authStub.admin, { isFavorite: true })).resolves.toEqual(statResponse); - expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isFavorite: true }); + expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isFavorite: true }); }); it('should get the statistics for a user for all assets', async () => { - assetMock.getStatistics.mockResolvedValue(stats); + mocks.asset.getStatistics.mockResolvedValue(stats); await expect(sut.getStatistics(authStub.admin, {})).resolves.toEqual(statResponse); - expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, {}); + expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, {}); }); }); describe('getRandom', () => { it('should get own random assets', async () => { - assetMock.getRandom.mockResolvedValue([assetStub.image]); + mocks.partner.getAll.mockResolvedValue([]); + mocks.asset.getRandom.mockResolvedValue([assetStub.image]); + await sut.getRandom(authStub.admin, 1); - expect(assetMock.getRandom).toHaveBeenCalledWith([authStub.admin.user.id], 1); + + expect(mocks.asset.getRandom).toHaveBeenCalledWith([authStub.admin.user.id], 1); }); it('should not include partner assets if not in timeline', async () => { - assetMock.getRandom.mockResolvedValue([assetStub.image]); - partnerMock.getAll.mockResolvedValue([{ ...partnerStub.user1ToAdmin1, inTimeline: false }]); + mocks.asset.getRandom.mockResolvedValue([assetStub.image]); + mocks.partner.getAll.mockResolvedValue([{ ...partnerStub.user1ToAdmin1, inTimeline: false }]); await sut.getRandom(authStub.admin, 1); - expect(assetMock.getRandom).toHaveBeenCalledWith([authStub.admin.user.id], 1); + expect(mocks.asset.getRandom).toHaveBeenCalledWith([authStub.admin.user.id], 1); }); it('should include partner assets if in timeline', async () => { - assetMock.getRandom.mockResolvedValue([assetStub.image]); - partnerMock.getAll.mockResolvedValue([partnerStub.user1ToAdmin1]); + mocks.asset.getRandom.mockResolvedValue([assetStub.image]); + mocks.partner.getAll.mockResolvedValue([partnerStub.user1ToAdmin1]); await sut.getRandom(authStub.admin, 1); - expect(assetMock.getRandom).toHaveBeenCalledWith([userStub.admin.id, userStub.user1.id], 1); + expect(mocks.asset.getRandom).toHaveBeenCalledWith([userStub.admin.id, userStub.user1.id], 1); }); }); describe('get', () => { it('should allow owner access', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue(assetStub.image); await sut.get(authStub.admin, assetStub.image.id); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith( + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([assetStub.image.id]), ); }); it('should allow shared link access', async () => { - accessMock.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue(assetStub.image); await sut.get(authStub.adminSharedLink, assetStub.image.id); - expect(accessMock.asset.checkSharedLinkAccess).toHaveBeenCalledWith( + expect(mocks.access.asset.checkSharedLinkAccess).toHaveBeenCalledWith( authStub.adminSharedLink.sharedLink?.id, new Set([assetStub.image.id]), ); }); it('should strip metadata for shared link if exif is disabled', async () => { - accessMock.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue(assetStub.image); const result = await sut.get( { ...authStub.adminSharedLink, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } }, @@ -194,27 +182,27 @@ describe(AssetService.name, () => { expect(result).toEqual(expect.objectContaining({ hasMetadata: false })); expect(result).not.toHaveProperty('exifInfo'); - expect(accessMock.asset.checkSharedLinkAccess).toHaveBeenCalledWith( + expect(mocks.access.asset.checkSharedLinkAccess).toHaveBeenCalledWith( authStub.adminSharedLink.sharedLink?.id, new Set([assetStub.image.id]), ); }); it('should allow partner sharing access', async () => { - accessMock.asset.checkPartnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue(assetStub.image); await sut.get(authStub.admin, assetStub.image.id); - expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith( + expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([assetStub.image.id]), ); }); it('should allow shared album access', async () => { - accessMock.asset.checkAlbumAccess.mockResolvedValue(new Set([assetStub.image.id])); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.access.asset.checkAlbumAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.asset.getById.mockResolvedValue(assetStub.image); await sut.get(authStub.admin, assetStub.image.id); - expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith( + expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([assetStub.image.id]), ); @@ -222,17 +210,17 @@ describe(AssetService.name, () => { it('should throw an error for no access', async () => { await expect(sut.get(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException); - expect(assetMock.getById).not.toHaveBeenCalled(); + expect(mocks.asset.getById).not.toHaveBeenCalled(); }); it('should throw an error for an invalid shared link', async () => { await expect(sut.get(authStub.adminSharedLink, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.asset.checkOwnerAccess).not.toHaveBeenCalled(); - expect(assetMock.getById).not.toHaveBeenCalled(); + expect(mocks.access.asset.checkOwnerAccess).not.toHaveBeenCalled(); + expect(mocks.asset.getById).not.toHaveBeenCalled(); }); it('should throw an error if the asset could not be found', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); await expect(sut.get(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException); }); }); @@ -242,40 +230,40 @@ describe(AssetService.name, () => { await expect(sut.update(authStub.admin, 'asset-1', { isArchived: false })).rejects.toBeInstanceOf( BadRequestException, ); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should update the asset', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - assetMock.getById.mockResolvedValue(assetStub.image); - assetMock.update.mockResolvedValue(assetStub.image); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.asset.getById.mockResolvedValue(assetStub.image); + mocks.asset.update.mockResolvedValue(assetStub.image); await sut.update(authStub.admin, 'asset-1', { isFavorite: true }); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-1', isFavorite: true }); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-1', isFavorite: true }); }); it('should update the exif description', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - assetMock.getById.mockResolvedValue(assetStub.image); - assetMock.update.mockResolvedValue(assetStub.image); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.asset.getById.mockResolvedValue(assetStub.image); + mocks.asset.update.mockResolvedValue(assetStub.image); await sut.update(authStub.admin, 'asset-1', { description: 'Test description' }); - expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', description: 'Test description' }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', description: 'Test description' }); }); it('should update the exif rating', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - assetMock.getById.mockResolvedValueOnce(assetStub.image); - assetMock.update.mockResolvedValueOnce(assetStub.image); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.asset.getById.mockResolvedValueOnce(assetStub.image); + mocks.asset.update.mockResolvedValueOnce(assetStub.image); await sut.update(authStub.admin, 'asset-1', { rating: 3 }); - expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', rating: 3 }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', rating: 3 }); }); it('should fail linking a live video if the motion part could not be found', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); await expect( sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { @@ -283,20 +271,20 @@ describe(AssetService.name, () => { }), ).rejects.toBeInstanceOf(BadRequestException); - expect(assetMock.update).not.toHaveBeenCalledWith({ + expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }); - expect(assetMock.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); - expect(eventMock.emit).not.toHaveBeenCalledWith('asset.show', { + expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); + expect(mocks.event.emit).not.toHaveBeenCalledWith('asset.show', { assetId: assetStub.livePhotoMotionAsset.id, userId: userStub.admin.id, }); }); it('should fail linking a live video if the motion part is not a video', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); - assetMock.getById.mockResolvedValue(assetStub.livePhotoStillAsset); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset); await expect( sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { @@ -304,20 +292,20 @@ describe(AssetService.name, () => { }), ).rejects.toBeInstanceOf(BadRequestException); - expect(assetMock.update).not.toHaveBeenCalledWith({ + expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }); - expect(assetMock.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); - expect(eventMock.emit).not.toHaveBeenCalledWith('asset.show', { + expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); + expect(mocks.event.emit).not.toHaveBeenCalledWith('asset.show', { assetId: assetStub.livePhotoMotionAsset.id, userId: userStub.admin.id, }); }); it('should fail linking a live video if the motion part has a different owner', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); - assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); await expect( sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { @@ -325,79 +313,79 @@ describe(AssetService.name, () => { }), ).rejects.toBeInstanceOf(BadRequestException); - expect(assetMock.update).not.toHaveBeenCalledWith({ + expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }); - expect(assetMock.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); - expect(eventMock.emit).not.toHaveBeenCalledWith('asset.show', { + expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); + expect(mocks.event.emit).not.toHaveBeenCalledWith('asset.show', { assetId: assetStub.livePhotoMotionAsset.id, userId: userStub.admin.id, }); }); it('should link a live video', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); - assetMock.getById.mockResolvedValueOnce({ + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + mocks.asset.getById.mockResolvedValueOnce({ ...assetStub.livePhotoMotionAsset, ownerId: authStub.admin.user.id, isVisible: true, }); - assetMock.getById.mockResolvedValueOnce(assetStub.image); - assetMock.update.mockResolvedValue(assetStub.image); + mocks.asset.getById.mockResolvedValueOnce(assetStub.image); + mocks.asset.update.mockResolvedValue(assetStub.image); await sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }); - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false }); - expect(eventMock.emit).toHaveBeenCalledWith('asset.hide', { + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false }); + expect(mocks.event.emit).toHaveBeenCalledWith('asset.hide', { assetId: assetStub.livePhotoMotionAsset.id, userId: userStub.admin.id, }); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }); }); it('should throw an error if asset could not be found after update', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await expect(sut.update(authStub.admin, 'asset-1', { isFavorite: true })).rejects.toBeInstanceOf( BadRequestException, ); }); it('should unlink a live video', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); - assetMock.getById.mockResolvedValueOnce(assetStub.livePhotoStillAsset); - assetMock.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); - assetMock.update.mockResolvedValueOnce(assetStub.image); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoStillAsset); + mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); + mocks.asset.update.mockResolvedValueOnce(assetStub.image); await sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null }); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: null, }); - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); - expect(eventMock.emit).toHaveBeenCalledWith('asset.show', { + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); + expect(mocks.event.emit).toHaveBeenCalledWith('asset.show', { assetId: assetStub.livePhotoMotionAsset.id, userId: userStub.admin.id, }); }); it('should fail unlinking a live video if the asset could not be found', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); // eslint-disable-next-line unicorn/no-useless-undefined - assetMock.getById.mockResolvedValueOnce(undefined); + mocks.asset.getById.mockResolvedValueOnce(undefined); await expect( sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null }), ).rejects.toBeInstanceOf(BadRequestException); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(eventMock.emit).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.event.emit).not.toHaveBeenCalled(); }); }); @@ -412,9 +400,37 @@ describe(AssetService.name, () => { }); it('should update all assets', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true }); - expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true }); + expect(mocks.asset.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true }); + }); + + it('should not update Assets table if no relevant fields are provided', async () => { + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + await sut.updateAll(authStub.admin, { + ids: ['asset-1'], + latitude: 0, + longitude: 0, + isArchived: undefined, + isFavorite: undefined, + duplicateId: undefined, + rating: undefined, + }); + expect(mocks.asset.updateAll).not.toHaveBeenCalled(); + }); + + it('should update Assets table if isArchived field is provided', async () => { + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + await sut.updateAll(authStub.admin, { + ids: ['asset-1'], + latitude: 0, + longitude: 0, + isArchived: undefined, + isFavorite: false, + duplicateId: undefined, + rating: undefined, + }); + expect(mocks.asset.updateAll).toHaveBeenCalled(); }); }); @@ -428,26 +444,26 @@ describe(AssetService.name, () => { }); it('should force delete a batch of assets', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2'])); await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: true }); - expect(eventMock.emit).toHaveBeenCalledWith('assets.delete', { + expect(mocks.event.emit).toHaveBeenCalledWith('assets.delete', { assetIds: ['asset1', 'asset2'], userId: 'user-id', }); }); it('should soft delete a batch of assets', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2'])); await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: false }); - expect(assetMock.updateAll).toHaveBeenCalledWith(['asset1', 'asset2'], { + expect(mocks.asset.updateAll).toHaveBeenCalledWith(['asset1', 'asset2'], { deletedAt: expect.any(Date), status: AssetStatus.TRASHED, }); - expect(jobMock.queue.mock.calls).toEqual([]); + expect(mocks.job.queue.mock.calls).toEqual([]); }); }); @@ -461,28 +477,30 @@ describe(AssetService.name, () => { }); it('should immediately queue assets for deletion if trash is disabled', async () => { - assetMock.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] }); - systemMock.get.mockResolvedValue({ trash: { enabled: false } }); + const asset = factory.asset({ isOffline: false }); + + mocks.asset.streamDeletedAssets.mockReturnValue(makeStream([asset])); + mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: false } }); await expect(sut.handleAssetDeletionCheck()).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getAll).toHaveBeenCalledWith(expect.anything(), { trashedBefore: new Date() }); - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { name: JobName.ASSET_DELETION, data: { id: assetStub.image.id, deleteOnDisk: true } }, + expect(mocks.asset.streamDeletedAssets).toHaveBeenCalledWith(new Date()); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { name: JobName.ASSET_DELETION, data: { id: asset.id, deleteOnDisk: true } }, ]); }); it('should queue assets for deletion after trash duration', async () => { - assetMock.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] }); - systemMock.get.mockResolvedValue({ trash: { enabled: true, days: 7 } }); + const asset = factory.asset({ isOffline: false }); + + mocks.asset.streamDeletedAssets.mockReturnValue(makeStream([asset])); + mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: true, days: 7 } }); await expect(sut.handleAssetDeletionCheck()).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getAll).toHaveBeenCalledWith(expect.anything(), { - trashedBefore: DateTime.now().minus({ days: 7 }).toJSDate(), - }); - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { name: JobName.ASSET_DELETION, data: { id: assetStub.image.id, deleteOnDisk: true } }, + expect(mocks.asset.streamDeletedAssets).toHaveBeenCalledWith(DateTime.now().minus({ days: 7 }).toJSDate()); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { name: JobName.ASSET_DELETION, data: { id: asset.id, deleteOnDisk: true } }, ]); }); }); @@ -491,11 +509,11 @@ describe(AssetService.name, () => { it('should remove faces', async () => { const assetWithFace = { ...assetStub.image, faces: [faceStub.face1, faceStub.mergeFace1] }; - assetMock.getById.mockResolvedValue(assetWithFace); + mocks.asset.getById.mockResolvedValue(assetWithFace); await sut.handleAssetDeletion({ id: assetWithFace.id, deleteOnDisk: true }); - expect(jobMock.queue.mock.calls).toEqual([ + expect(mocks.job.queue.mock.calls).toEqual([ [ { name: JobName.DELETE_FILES, @@ -512,41 +530,43 @@ describe(AssetService.name, () => { ], ]); - expect(assetMock.remove).toHaveBeenCalledWith(assetWithFace); + expect(mocks.asset.remove).toHaveBeenCalledWith(assetWithFace); }); it('should update stack primary asset if deleted asset was primary asset in a stack', async () => { - assetMock.getById.mockResolvedValue(assetStub.primaryImage as AssetEntity); + mocks.stack.update.mockResolvedValue(factory.stack() as unknown as any); + mocks.asset.getById.mockResolvedValue(assetStub.primaryImage as AssetEntity); await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true }); - expect(stackMock.update).toHaveBeenCalledWith('stack-1', { + expect(mocks.stack.update).toHaveBeenCalledWith('stack-1', { id: 'stack-1', primaryAssetId: 'stack-child-asset-1', }); }); it('should delete the entire stack if deleted asset was the primary asset and the stack would only contain one asset afterwards', async () => { - assetMock.getById.mockResolvedValue({ + mocks.stack.delete.mockResolvedValue(); + mocks.asset.getById.mockResolvedValue({ ...assetStub.primaryImage, stack: { ...assetStub.primaryImage.stack, assets: assetStub.primaryImage.stack!.assets.slice(0, 2) }, } as AssetEntity); await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true }); - expect(stackMock.delete).toHaveBeenCalledWith('stack-1'); + expect(mocks.stack.delete).toHaveBeenCalledWith('stack-1'); }); it('should delete a live photo', async () => { - assetMock.getById.mockResolvedValue(assetStub.livePhotoStillAsset); - assetMock.getLivePhotoCount.mockResolvedValue(0); + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset); + mocks.asset.getLivePhotoCount.mockResolvedValue(0); await sut.handleAssetDeletion({ id: assetStub.livePhotoStillAsset.id, deleteOnDisk: true, }); - expect(jobMock.queue.mock.calls).toEqual([ + expect(mocks.job.queue.mock.calls).toEqual([ [ { name: JobName.ASSET_DELETION, @@ -568,15 +588,15 @@ describe(AssetService.name, () => { }); it('should not delete a live motion part if it is being used by another asset', async () => { - assetMock.getLivePhotoCount.mockResolvedValue(2); - assetMock.getById.mockResolvedValue(assetStub.livePhotoStillAsset); + mocks.asset.getLivePhotoCount.mockResolvedValue(2); + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset); await sut.handleAssetDeletion({ id: assetStub.livePhotoStillAsset.id, deleteOnDisk: true, }); - expect(jobMock.queue.mock.calls).toEqual([ + expect(mocks.job.queue.mock.calls).toEqual([ [ { name: JobName.DELETE_FILES, @@ -589,9 +609,9 @@ describe(AssetService.name, () => { }); it('should update usage', async () => { - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.asset.getById.mockResolvedValue(assetStub.image); await sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true }); - expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.image.ownerId, -5000); + expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.image.ownerId, -5000); }); it('should fail if asset could not be found', async () => { @@ -603,27 +623,27 @@ describe(AssetService.name, () => { describe('run', () => { it('should run the refresh faces job', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_FACES }); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.FACE_DETECTION, data: { id: 'asset-1' } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.FACE_DETECTION, data: { id: 'asset-1' } }]); }); it('should run the refresh metadata job', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_METADATA }); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.METADATA_EXTRACTION, data: { id: 'asset-1' } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.METADATA_EXTRACTION, data: { id: 'asset-1' } }]); }); it('should run the refresh thumbnails job', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REGENERATE_THUMBNAIL }); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1' } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1' } }]); }); it('should run the transcode video', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.TRANSCODE_VIDEO }); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } }]); }); }); @@ -631,7 +651,7 @@ describe(AssetService.name, () => { it('get assets by device id', async () => { const assets = [assetStub.image, assetStub.image1]; - assetMock.getAllByDeviceId.mockResolvedValue(assets.map((asset) => asset.deviceAssetId)); + mocks.asset.getAllByDeviceId.mockResolvedValue(assets.map((asset) => asset.deviceAssetId)); const deviceId = 'device-id'; const result = await sut.getUserAssetsByDeviceId(authStub.user1, deviceId); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 3913c0ce4c..56b7f7743c 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -1,6 +1,7 @@ -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import _ from 'lodash'; import { DateTime, Duration } from 'luxon'; +import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { OnJob } from 'src/decorators'; import { AssetResponseDto, @@ -20,20 +21,12 @@ import { import { AuthDto } from 'src/dtos/auth.dto'; import { MemoryLaneDto } from 'src/dtos/search.dto'; import { AssetEntity } from 'src/entities/asset.entity'; -import { AssetStatus, Permission } from 'src/enum'; -import { - ISidecarWriteJob, - JOBS_ASSET_PAGINATION_SIZE, - JobItem, - JobName, - JobOf, - JobStatus, - QueueName, -} from 'src/interfaces/job.interface'; +import { AssetStatus, JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { BaseService } from 'src/services/base.service'; +import { ISidecarWriteJob, JobItem, JobOf } from 'src/types'; import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util'; -import { usePagination } from 'src/utils/pagination'; +@Injectable() export class AssetService extends BaseService { async getMemoryLane(auth: AuthDto, dto: MemoryLaneDto): Promise { const partnerIds = await getMyPartnerIds({ @@ -44,12 +37,15 @@ export class AssetService extends BaseService { const userIds = [auth.user.id, ...partnerIds]; const groups = await this.assetRepository.getByDayOfYear(userIds, dto); - return groups.map(({ yearsAgo, assets }) => ({ - yearsAgo, - // TODO move this to clients - title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} ago`, - assets: assets.map((asset) => mapAsset(asset, { auth })), - })); + return groups.map(({ year, assets }) => { + const yearsAgo = DateTime.utc().year - year; + return { + yearsAgo, + // TODO move this to clients + title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} ago`, + assets: assets.map((asset) => mapAsset(asset as AssetEntity, { auth })), + }; + }); } async getStatistics(auth: AuthDto, dto: AssetStatsDto) { @@ -142,7 +138,14 @@ export class AssetService extends BaseService { await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude }); } - await this.assetRepository.updateAll(ids, options); + if ( + options.isArchived != undefined || + options.isFavorite != undefined || + options.duplicateId != undefined || + options.rating != undefined + ) { + await this.assetRepository.updateAll(ids, options); + } } @OnJob({ name: JobName.ASSET_DELETION_CHECK, queue: QueueName.BACKGROUND_TASK }) @@ -152,22 +155,30 @@ export class AssetService extends BaseService { const trashedBefore = DateTime.now() .minus(Duration.fromObject({ days: trashedDays })) .toJSDate(); - const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.assetRepository.getAll(pagination, { trashedBefore }), - ); - for await (const assets of assetPagination) { - await this.jobRepository.queueAll( - assets.map((asset) => ({ - name: JobName.ASSET_DELETION, - data: { - id: asset.id, - deleteOnDisk: !asset.isOffline, - }, - })), - ); + let chunk: Array<{ id: string; isOffline: boolean }> = []; + const queueChunk = async () => { + if (chunk.length > 0) { + await this.jobRepository.queueAll( + chunk.map(({ id, isOffline }) => ({ + name: JobName.ASSET_DELETION, + data: { id, deleteOnDisk: !isOffline }, + })), + ); + chunk = []; + } + }; + + const assets = this.assetRepository.streamDeletedAssets(trashedBefore); + for await (const asset of assets) { + chunk.push(asset); + if (chunk.length >= JOBS_ASSET_PAGINATION_SIZE) { + await queueChunk(); + } } + await queueChunk(); + return JobStatus.SUCCESS; } diff --git a/server/src/services/audit.service.spec.ts b/server/src/services/audit.service.spec.ts index dd853042fb..6ef139f506 100644 --- a/server/src/services/audit.service.spec.ts +++ b/server/src/services/audit.service.spec.ts @@ -1,28 +1,15 @@ import { BadRequestException } from '@nestjs/common'; import { FileReportItemDto } from 'src/dtos/audit.dto'; -import { AssetFileType, AssetPathType, DatabaseAction, EntityType, PersonPathType, UserPathType } from 'src/enum'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { JobStatus } from 'src/interfaces/job.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { AssetFileType, AssetPathType, JobStatus, PersonPathType, UserPathType } from 'src/enum'; import { AuditService } from 'src/services/audit.service'; -import { IAuditRepository } from 'src/types'; -import { auditStub } from 'test/fixtures/audit.stub'; -import { authStub } from 'test/fixtures/auth.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(AuditService.name, () => { let sut: AuditService; - let auditMock: Mocked; - let assetMock: Mocked; - let cryptoMock: Mocked; - let personMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, auditMock, assetMock, cryptoMock, personMock, userMock } = newTestService(AuditService)); + ({ sut, mocks } = newTestService(AuditService)); }); it('should work', () => { @@ -31,42 +18,11 @@ describe(AuditService.name, () => { describe('handleCleanup', () => { it('should delete old audit entries', async () => { + mocks.audit.removeBefore.mockResolvedValue(); + await expect(sut.handleCleanup()).resolves.toBe(JobStatus.SUCCESS); - expect(auditMock.removeBefore).toHaveBeenCalledWith(expect.any(Date)); - }); - }); - describe('getDeletes', () => { - it('should require full sync if the request is older than 100 days', async () => { - auditMock.getAfter.mockResolvedValue([]); - - const date = new Date(2022, 0, 1); - await expect(sut.getDeletes(authStub.admin, { after: date, entityType: EntityType.ASSET })).resolves.toEqual({ - needsFullSync: true, - ids: [], - }); - - expect(auditMock.getAfter).toHaveBeenCalledWith(date, { - action: DatabaseAction.DELETE, - userIds: [authStub.admin.user.id], - entityType: EntityType.ASSET, - }); - }); - - it('should get any new or updated assets and deleted ids', async () => { - auditMock.getAfter.mockResolvedValue([auditStub.delete.entityId]); - - const date = new Date(); - await expect(sut.getDeletes(authStub.admin, { after: date, entityType: EntityType.ASSET })).resolves.toEqual({ - needsFullSync: false, - ids: ['asset-deleted'], - }); - - expect(auditMock.getAfter).toHaveBeenCalledWith(date, { - action: DatabaseAction.DELETE, - userIds: [authStub.admin.user.id], - entityType: EntityType.ASSET, - }); + expect(mocks.audit.removeBefore).toHaveBeenCalledWith(expect.any(Date)); }); }); @@ -74,7 +30,7 @@ describe(AuditService.name, () => { it('should fail if the file is not in the immich path', async () => { await expect(sut.getChecksums({ filenames: ['foo/bar'] })).rejects.toBeInstanceOf(BadRequestException); - expect(cryptoMock.hashFile).not.toHaveBeenCalled(); + expect(mocks.crypto.hashFile).not.toHaveBeenCalled(); }); it('should get checksum for valid file', async () => { @@ -82,7 +38,7 @@ describe(AuditService.name, () => { { filename: './upload/my-file.jpg', checksum: expect.any(String) }, ]); - expect(cryptoMock.hashFile).toHaveBeenCalledWith('./upload/my-file.jpg'); + expect(mocks.crypto.hashFile).toHaveBeenCalledWith('./upload/my-file.jpg'); }); }); @@ -94,10 +50,10 @@ describe(AuditService.name, () => { ]), ).rejects.toBeInstanceOf(BadRequestException); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(assetMock.upsertFile).not.toHaveBeenCalled(); - expect(personMock.update).not.toHaveBeenCalled(); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('should update encoded video path', async () => { @@ -109,10 +65,10 @@ describe(AuditService.name, () => { } as FileReportItemDto, ]); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'my-id', encodedVideoPath: './upload/my-video.mp4' }); - expect(assetMock.upsertFile).not.toHaveBeenCalled(); - expect(personMock.update).not.toHaveBeenCalled(); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'my-id', encodedVideoPath: './upload/my-video.mp4' }); + expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('should update preview path', async () => { @@ -124,14 +80,14 @@ describe(AuditService.name, () => { } as FileReportItemDto, ]); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ + expect(mocks.asset.upsertFile).toHaveBeenCalledWith({ assetId: 'my-id', type: AssetFileType.PREVIEW, path: './upload/my-preview.png', }); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(personMock.update).not.toHaveBeenCalled(); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('should update thumbnail path', async () => { @@ -143,14 +99,14 @@ describe(AuditService.name, () => { } as FileReportItemDto, ]); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ + expect(mocks.asset.upsertFile).toHaveBeenCalledWith({ assetId: 'my-id', type: AssetFileType.THUMBNAIL, path: './upload/my-thumbnail.webp', }); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(personMock.update).not.toHaveBeenCalled(); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('should update original path', async () => { @@ -162,10 +118,10 @@ describe(AuditService.name, () => { } as FileReportItemDto, ]); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'my-id', originalPath: './upload/my-original.png' }); - expect(assetMock.upsertFile).not.toHaveBeenCalled(); - expect(personMock.update).not.toHaveBeenCalled(); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'my-id', originalPath: './upload/my-original.png' }); + expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('should update sidecar path', async () => { @@ -177,10 +133,10 @@ describe(AuditService.name, () => { } as FileReportItemDto, ]); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'my-id', sidecarPath: './upload/my-sidecar.xmp' }); - expect(assetMock.upsertFile).not.toHaveBeenCalled(); - expect(personMock.update).not.toHaveBeenCalled(); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'my-id', sidecarPath: './upload/my-sidecar.xmp' }); + expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('should update face path', async () => { @@ -192,10 +148,10 @@ describe(AuditService.name, () => { } as FileReportItemDto, ]); - expect(personMock.update).toHaveBeenCalledWith({ id: 'my-id', thumbnailPath: './upload/my-face.jpg' }); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(assetMock.upsertFile).not.toHaveBeenCalled(); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'my-id', thumbnailPath: './upload/my-face.jpg' }); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('should update profile path', async () => { @@ -207,10 +163,10 @@ describe(AuditService.name, () => { } as FileReportItemDto, ]); - expect(userMock.update).toHaveBeenCalledWith('my-id', { profileImagePath: './upload/my-profile-pic.jpg' }); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(assetMock.upsertFile).not.toHaveBeenCalled(); - expect(personMock.update).not.toHaveBeenCalled(); + expect(mocks.user.update).toHaveBeenCalledWith('my-id', { profileImagePath: './upload/my-profile-pic.jpg' }); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.asset.upsertFile).not.toHaveBeenCalled(); + expect(mocks.person.update).not.toHaveBeenCalled(); }); }); }); diff --git a/server/src/services/audit.service.ts b/server/src/services/audit.service.ts index 611f8f69d3..3948469765 100644 --- a/server/src/services/audit.service.ts +++ b/server/src/services/audit.service.ts @@ -1,28 +1,20 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; import { resolve } from 'node:path'; -import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; +import { AUDIT_LOG_MAX_DURATION, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { OnJob } from 'src/decorators'; -import { - AuditDeletesDto, - AuditDeletesResponseDto, - FileChecksumDto, - FileChecksumResponseDto, - FileReportItemDto, - PathEntityType, -} from 'src/dtos/audit.dto'; -import { AuthDto } from 'src/dtos/auth.dto'; +import { FileChecksumDto, FileChecksumResponseDto, FileReportItemDto, PathEntityType } from 'src/dtos/audit.dto'; import { AssetFileType, AssetPathType, - DatabaseAction, - Permission, + JobName, + JobStatus, PersonPathType, + QueueName, StorageFolder, UserPathType, } from 'src/enum'; -import { JobName, JOBS_ASSET_PAGINATION_SIZE, JobStatus, QueueName } from 'src/interfaces/job.interface'; import { BaseService } from 'src/services/base.service'; import { getAssetFiles } from 'src/utils/asset.util'; import { usePagination } from 'src/utils/pagination'; @@ -35,24 +27,6 @@ export class AuditService extends BaseService { return JobStatus.SUCCESS; } - async getDeletes(auth: AuthDto, dto: AuditDeletesDto): Promise { - const userId = dto.userId || auth.user.id; - await this.requireAccess({ auth, permission: Permission.TIMELINE_READ, ids: [userId] }); - - const audits = await this.auditRepository.getAfter(dto.after, { - userIds: [userId], - entityType: dto.entityType, - action: DatabaseAction.DELETE, - }); - - const duration = DateTime.now().diff(DateTime.fromJSDate(dto.after)); - - return { - needsFullSync: duration > AUDIT_LOG_MAX_DURATION, - ids: audits, - }; - } - async getChecksums(dto: FileChecksumDto) { const results: FileChecksumResponseDto[] = []; for (const filename of dto.filenames) { diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 780d802922..b1bd3332bf 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -3,22 +3,13 @@ import { AuthDto, SignUpDto } from 'src/dtos/auth.dto'; import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; import { AuthType, Permission } from 'src/enum'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { ISessionRepository } from 'src/interfaces/session.interface'; -import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; import { AuthService } from 'src/services/auth.service'; -import { IApiKeyRepository, IOAuthRepository } from 'src/types'; -import { keyStub } from 'test/fixtures/api-key.stub'; -import { authStub } from 'test/fixtures/auth.stub'; import { sessionStub } from 'test/fixtures/session.stub'; import { sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { factory } from 'test/small.factory'; +import { newTestService, ServiceMocks } from 'test/utils'; const oauthResponse = { accessToken: 'cmFuZG9tLWJ5dGVz', @@ -58,23 +49,14 @@ const oauthUserWithDefaultQuota = { describe('AuthService', () => { let sut: AuthService; - - let cryptoMock: Mocked; - let eventMock: Mocked; - let keyMock: Mocked; - let oauthMock: Mocked; - let sessionMock: Mocked; - let sharedLinkMock: Mocked; - let systemMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, cryptoMock, eventMock, keyMock, oauthMock, sessionMock, sharedLinkMock, systemMock, userMock } = - newTestService(AuthService)); + ({ sut, mocks } = newTestService(AuthService)); - oauthMock.authorize.mockResolvedValue('access-token'); - oauthMock.getProfile.mockResolvedValue({ sub, email }); - oauthMock.getLogoutEndpoint.mockResolvedValue('http://end-session-endpoint'); + mocks.oauth.authorize.mockResolvedValue('access-token'); + mocks.oauth.getProfile.mockResolvedValue({ sub, email }); + mocks.oauth.getLogoutEndpoint.mockResolvedValue('http://end-session-endpoint'); }); it('should be defined', () => { @@ -83,32 +65,41 @@ describe('AuthService', () => { describe('onBootstrap', () => { it('should init the repo', () => { + mocks.oauth.init.mockResolvedValue(); + sut.onBootstrap(); - expect(oauthMock.init).toHaveBeenCalled(); + + expect(mocks.oauth.init).toHaveBeenCalled(); }); }); describe('login', () => { it('should throw an error if password login is disabled', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.disabled); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.disabled); + await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); }); it('should check the user exists', async () => { - userMock.getByEmail.mockResolvedValue(void 0); + mocks.user.getByEmail.mockResolvedValue(void 0); + await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); - expect(userMock.getByEmail).toHaveBeenCalledTimes(1); + + expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); }); it('should check the user has a password', async () => { - userMock.getByEmail.mockResolvedValue({} as UserEntity); + mocks.user.getByEmail.mockResolvedValue({} as UserEntity); + await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); - expect(userMock.getByEmail).toHaveBeenCalledTimes(1); + + expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); }); it('should successfully log the user in', async () => { - userMock.getByEmail.mockResolvedValue(userStub.user1); - sessionMock.create.mockResolvedValue(sessionStub.valid); + mocks.user.getByEmail.mockResolvedValue(userStub.user1); + mocks.session.create.mockResolvedValue(sessionStub.valid); + await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual({ accessToken: 'cmFuZG9tLWJ5dGVz', userId: 'user-id', @@ -118,7 +109,8 @@ describe('AuthService', () => { isAdmin: false, shouldChangePassword: false, }); - expect(userMock.getByEmail).toHaveBeenCalledTimes(1); + + expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); }); }); @@ -127,23 +119,23 @@ describe('AuthService', () => { const auth = { user: { email: 'test@imimch.com' } } as AuthDto; const dto = { password: 'old-password', newPassword: 'new-password' }; - userMock.getByEmail.mockResolvedValue({ + mocks.user.getByEmail.mockResolvedValue({ email: 'test@immich.com', password: 'hash-password', } as UserEntity); - userMock.update.mockResolvedValue(userStub.user1); + mocks.user.update.mockResolvedValue(userStub.user1); await sut.changePassword(auth, dto); - expect(userMock.getByEmail).toHaveBeenCalledWith(auth.user.email, true); - expect(cryptoMock.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password'); + expect(mocks.user.getByEmail).toHaveBeenCalledWith(auth.user.email, true); + expect(mocks.crypto.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password'); }); it('should throw when auth user email is not found', async () => { const auth = { user: { email: 'test@imimch.com' } } as AuthDto; const dto = { password: 'old-password', newPassword: 'new-password' }; - userMock.getByEmail.mockResolvedValue(void 0); + mocks.user.getByEmail.mockResolvedValue(void 0); await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(UnauthorizedException); }); @@ -152,9 +144,9 @@ describe('AuthService', () => { const auth = { user: { email: 'test@imimch.com' } as UserEntity }; const dto = { password: 'old-password', newPassword: 'new-password' }; - cryptoMock.compareBcrypt.mockReturnValue(false); + mocks.crypto.compareBcrypt.mockReturnValue(false); - userMock.getByEmail.mockResolvedValue({ + mocks.user.getByEmail.mockResolvedValue({ email: 'test@immich.com', password: 'hash-password', } as UserEntity); @@ -166,7 +158,7 @@ describe('AuthService', () => { const auth = { user: { email: 'test@imimch.com' } } as AuthDto; const dto = { password: 'old-password', newPassword: 'new-password' }; - userMock.getByEmail.mockResolvedValue({ + mocks.user.getByEmail.mockResolvedValue({ email: 'test@immich.com', password: '', } as UserEntity); @@ -177,8 +169,10 @@ describe('AuthService', () => { describe('logout', () => { it('should return the end session endpoint', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.enabled); - const auth = { user: { id: '123' } } as AuthDto; + const auth = factory.auth(); + + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); + await expect(sut.logout(auth, AuthType.OAUTH)).resolves.toEqual({ successful: true, redirectUri: 'http://end-session-endpoint', @@ -186,7 +180,7 @@ describe('AuthService', () => { }); it('should return the default redirect', async () => { - const auth = { user: { id: '123' } } as AuthDto; + const auth = factory.auth(); await expect(sut.logout(auth, AuthType.PASSWORD)).resolves.toEqual({ successful: true, @@ -196,14 +190,15 @@ describe('AuthService', () => { it('should delete the access token', async () => { const auth = { user: { id: '123' }, session: { id: 'token123' } } as AuthDto; + mocks.session.delete.mockResolvedValue(); await expect(sut.logout(auth, AuthType.PASSWORD)).resolves.toEqual({ successful: true, redirectUri: '/auth/login?autoLaunch=0', }); - expect(sessionMock.delete).toHaveBeenCalledWith('token123'); - expect(eventMock.emit).toHaveBeenCalledWith('session.delete', { sessionId: 'token123' }); + expect(mocks.session.delete).toHaveBeenCalledWith('token123'); + expect(mocks.event.emit).toHaveBeenCalledWith('session.delete', { sessionId: 'token123' }); }); it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => { @@ -220,19 +215,22 @@ describe('AuthService', () => { const dto: SignUpDto = { email: 'test@immich.com', password: 'password', name: 'immich admin' }; it('should only allow one admin', async () => { - userMock.getAdmin.mockResolvedValue({} as UserEntity); + mocks.user.getAdmin.mockResolvedValue({} as UserEntity); + await expect(sut.adminSignUp(dto)).rejects.toBeInstanceOf(BadRequestException); - expect(userMock.getAdmin).toHaveBeenCalled(); + + expect(mocks.user.getAdmin).toHaveBeenCalled(); }); it('should sign up the admin', async () => { - userMock.getAdmin.mockResolvedValue(void 0); - userMock.create.mockResolvedValue({ + mocks.user.getAdmin.mockResolvedValue(void 0); + mocks.user.create.mockResolvedValue({ ...dto, id: 'admin', createdAt: new Date('2021-01-01'), metadata: [] as UserMetadataEntity[], } as UserEntity); + await expect(sut.adminSignUp(dto)).resolves.toMatchObject({ avatarColor: expect.any(String), id: 'admin', @@ -240,8 +238,9 @@ describe('AuthService', () => { email: 'test@immich.com', name: 'immich admin', }); - expect(userMock.getAdmin).toHaveBeenCalled(); - expect(userMock.create).toHaveBeenCalled(); + + expect(mocks.user.getAdmin).toHaveBeenCalled(); + expect(mocks.user.create).toHaveBeenCalled(); }); }); @@ -257,8 +256,9 @@ describe('AuthService', () => { }); it('should validate using authorization header', async () => { - userMock.get.mockResolvedValue(userStub.user1); - sessionMock.getByToken.mockResolvedValue(sessionStub.valid); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any); + await expect( sut.authenticate({ headers: { authorization: 'Bearer auth_token' }, @@ -274,6 +274,8 @@ describe('AuthService', () => { describe('validate - shared key', () => { it('should not accept a non-existent key', async () => { + mocks.sharedLink.getByKey.mockResolvedValue(void 0); + await expect( sut.authenticate({ headers: { 'x-immich-share-key': 'key' }, @@ -284,7 +286,8 @@ describe('AuthService', () => { }); it('should not accept an expired key', async () => { - sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.expired); + mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.expired); + await expect( sut.authenticate({ headers: { 'x-immich-share-key': 'key' }, @@ -295,7 +298,8 @@ describe('AuthService', () => { }); it('should not accept a key on a non-shared route', async () => { - sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid); + await expect( sut.authenticate({ headers: { 'x-immich-share-key': 'key' }, @@ -306,8 +310,9 @@ describe('AuthService', () => { }); it('should not accept a key without a user', async () => { - sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.expired); - userMock.get.mockResolvedValue(void 0); + mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.expired); + mocks.user.get.mockResolvedValue(void 0); + await expect( sut.authenticate({ headers: { 'x-immich-share-key': 'key' }, @@ -318,8 +323,9 @@ describe('AuthService', () => { }); it('should accept a base64url key', async () => { - sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.valid); - userMock.get.mockResolvedValue(userStub.admin); + mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid); + mocks.user.get.mockResolvedValue(userStub.admin); + await expect( sut.authenticate({ headers: { 'x-immich-share-key': sharedLinkStub.valid.key.toString('base64url') }, @@ -330,12 +336,13 @@ describe('AuthService', () => { user: userStub.admin, sharedLink: sharedLinkStub.valid, }); - expect(sharedLinkMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); + expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); }); it('should accept a hex key', async () => { - sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.valid); - userMock.get.mockResolvedValue(userStub.admin); + mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid); + mocks.user.get.mockResolvedValue(userStub.admin); + await expect( sut.authenticate({ headers: { 'x-immich-share-key': sharedLinkStub.valid.key.toString('hex') }, @@ -346,13 +353,14 @@ describe('AuthService', () => { user: userStub.admin, sharedLink: sharedLinkStub.valid, }); - expect(sharedLinkMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); + expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); }); }); describe('validate - user token', () => { it('should throw if no token is found', async () => { - sessionMock.getByToken.mockResolvedValue(void 0); + mocks.session.getByToken.mockResolvedValue(void 0); + await expect( sut.authenticate({ headers: { 'x-immich-user-token': 'auth_token' }, @@ -363,7 +371,8 @@ describe('AuthService', () => { }); it('should return an auth dto', async () => { - sessionMock.getByToken.mockResolvedValue(sessionStub.valid); + mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any); + await expect( sut.authenticate({ headers: { cookie: 'immich_access_token=auth_token' }, @@ -377,7 +386,8 @@ describe('AuthService', () => { }); it('should throw if admin route and not an admin', async () => { - sessionMock.getByToken.mockResolvedValue(sessionStub.valid); + mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any); + await expect( sut.authenticate({ headers: { cookie: 'immich_access_token=auth_token' }, @@ -388,8 +398,9 @@ describe('AuthService', () => { }); it('should update when access time exceeds an hour', async () => { - sessionMock.getByToken.mockResolvedValue(sessionStub.inactive); - sessionMock.update.mockResolvedValue(sessionStub.valid); + mocks.session.getByToken.mockResolvedValue(sessionStub.inactive as any); + mocks.session.update.mockResolvedValue(sessionStub.valid); + await expect( sut.authenticate({ headers: { cookie: 'immich_access_token=auth_token' }, @@ -397,13 +408,14 @@ describe('AuthService', () => { metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, }), ).resolves.toBeDefined(); - expect(sessionMock.update.mock.calls[0][1]).toMatchObject({ id: 'not_active', updatedAt: expect.any(Date) }); + expect(mocks.session.update.mock.calls[0][1]).toMatchObject({ id: 'not_active', updatedAt: expect.any(Date) }); }); }); describe('validate - api key', () => { it('should throw an error if no api key is found', async () => { - keyMock.getKey.mockResolvedValue(void 0); + mocks.apiKey.getKey.mockResolvedValue(void 0); + await expect( sut.authenticate({ headers: { 'x-api-key': 'auth_token' }, @@ -411,11 +423,15 @@ describe('AuthService', () => { metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, }), ).rejects.toBeInstanceOf(UnauthorizedException); - expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)'); + expect(mocks.apiKey.getKey).toHaveBeenCalledWith('auth_token (hashed)'); }); it('should throw an error if api key has insufficient permissions', async () => { - keyMock.getKey.mockResolvedValue(keyStub.authKey); + const authUser = factory.authUser(); + const authApiKey = factory.authApiKey({ permissions: [] }); + + mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser }); + await expect( sut.authenticate({ headers: { 'x-api-key': 'auth_token' }, @@ -426,15 +442,19 @@ describe('AuthService', () => { }); it('should return an auth dto', async () => { - keyMock.getKey.mockResolvedValue(keyStub.authKey); + const authUser = factory.authUser(); + const authApiKey = factory.authApiKey({ permissions: [] }); + + mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser }); + await expect( sut.authenticate({ headers: { 'x-api-key': 'auth_token' }, queryParams: {}, metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, }), - ).resolves.toEqual({ user: userStub.admin, apiKey: keyStub.authKey }); - expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)'); + ).resolves.toEqual({ user: authUser, apiKey: expect.objectContaining(authApiKey) }); + expect(mocks.apiKey.getKey).toHaveBeenCalledWith('auth_token (hashed)'); }); }); @@ -452,14 +472,16 @@ describe('AuthService', () => { describe('authorize', () => { it('should fail if oauth is disabled', async () => { - systemMock.get.mockResolvedValue({ oauth: { enabled: false } }); + mocks.systemMetadata.get.mockResolvedValue({ oauth: { enabled: false } }); + await expect(sut.authorize({ redirectUri: 'https://demo.immich.app' })).rejects.toBeInstanceOf( BadRequestException, ); }); it('should authorize the user', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); + await sut.authorize({ redirectUri: 'https://demo.immich.app' }); }); }); @@ -470,71 +492,73 @@ describe('AuthService', () => { }); it('should not allow auto registering', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthEnabled); - userMock.getByEmail.mockResolvedValue(void 0); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); + mocks.user.getByEmail.mockResolvedValue(void 0); + await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf( BadRequestException, ); - expect(userMock.getByEmail).toHaveBeenCalledTimes(1); + + expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); }); it('should link an existing user', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthEnabled); - userMock.getByEmail.mockResolvedValue(userStub.user1); - userMock.update.mockResolvedValue(userStub.user1); - sessionMock.create.mockResolvedValue(sessionStub.valid); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); + mocks.user.getByEmail.mockResolvedValue(userStub.user1); + mocks.user.update.mockResolvedValue(userStub.user1); + mocks.session.create.mockResolvedValue(sessionStub.valid); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( oauthResponse, ); - expect(userMock.getByEmail).toHaveBeenCalledTimes(1); - expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { oauthId: sub }); + expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); + expect(mocks.user.update).toHaveBeenCalledWith(userStub.user1.id, { oauthId: sub }); }); it('should not link to a user with a different oauth sub', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); - userMock.getByEmail.mockResolvedValueOnce({ ...userStub.user1, oauthId: 'existing-sub' }); - userMock.getAdmin.mockResolvedValue(userStub.user1); - userMock.create.mockResolvedValue(userStub.user1); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); + mocks.user.getByEmail.mockResolvedValueOnce({ ...userStub.user1, oauthId: 'existing-sub' }); + mocks.user.getAdmin.mockResolvedValue(userStub.user1); + mocks.user.create.mockResolvedValue(userStub.user1); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toThrow( BadRequestException, ); - expect(userMock.update).not.toHaveBeenCalled(); - expect(userMock.create).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); + expect(mocks.user.create).not.toHaveBeenCalled(); }); it('should allow auto registering by default', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.enabled); - userMock.getByEmail.mockResolvedValue(void 0); - userMock.getAdmin.mockResolvedValue(userStub.user1); - userMock.create.mockResolvedValue(userStub.user1); - sessionMock.create.mockResolvedValue(sessionStub.valid); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); + mocks.user.getByEmail.mockResolvedValue(void 0); + mocks.user.getAdmin.mockResolvedValue(userStub.user1); + mocks.user.create.mockResolvedValue(userStub.user1); + mocks.session.create.mockResolvedValue(sessionStub.valid); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( oauthResponse, ); - expect(userMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create - expect(userMock.create).toHaveBeenCalledTimes(1); + expect(mocks.user.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create + expect(mocks.user.create).toHaveBeenCalledTimes(1); }); it('should throw an error if user should be auto registered but the email claim does not exist', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.enabled); - userMock.getByEmail.mockResolvedValue(void 0); - userMock.getAdmin.mockResolvedValue(userStub.user1); - userMock.create.mockResolvedValue(userStub.user1); - sessionMock.create.mockResolvedValue(sessionStub.valid); - oauthMock.getProfile.mockResolvedValue({ sub, email: undefined }); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); + mocks.user.getByEmail.mockResolvedValue(void 0); + mocks.user.getAdmin.mockResolvedValue(userStub.user1); + mocks.user.create.mockResolvedValue(userStub.user1); + mocks.session.create.mockResolvedValue(sessionStub.valid); + mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined }); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf( BadRequestException, ); - expect(userMock.getByEmail).not.toHaveBeenCalled(); - expect(userMock.create).not.toHaveBeenCalled(); + expect(mocks.user.getByEmail).not.toHaveBeenCalled(); + expect(mocks.user.create).not.toHaveBeenCalled(); }); for (const url of [ @@ -546,68 +570,73 @@ describe('AuthService', () => { 'app.immich:///oauth-callback?code=abc123', ]) { it(`should use the mobile redirect override for a url of ${url}`, async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); - userMock.getByOAuthId.mockResolvedValue(userStub.user1); - sessionMock.create.mockResolvedValue(sessionStub.valid); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); + mocks.user.getByOAuthId.mockResolvedValue(userStub.user1); + mocks.session.create.mockResolvedValue(sessionStub.valid); await sut.callback({ url }, loginDetails); - expect(oauthMock.getProfile).toHaveBeenCalledWith(expect.objectContaining({}), url, 'http://mobile-redirect'); + + expect(mocks.oauth.getProfile).toHaveBeenCalledWith(expect.objectContaining({}), url, 'http://mobile-redirect'); }); } it('should use the default quota', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); - userMock.getByEmail.mockResolvedValue(void 0); - userMock.getAdmin.mockResolvedValue(userStub.user1); - userMock.create.mockResolvedValue(userStub.user1); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); + mocks.user.getByEmail.mockResolvedValue(void 0); + mocks.user.getAdmin.mockResolvedValue(userStub.user1); + mocks.user.create.mockResolvedValue(userStub.user1); + mocks.session.create.mockResolvedValue(factory.session()); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( oauthResponse, ); - expect(userMock.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); + expect(mocks.user.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); }); it('should ignore an invalid storage quota', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); - userMock.getByEmail.mockResolvedValue(void 0); - userMock.getAdmin.mockResolvedValue(userStub.user1); - userMock.create.mockResolvedValue(userStub.user1); - oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 'abc' }); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); + mocks.user.getByEmail.mockResolvedValue(void 0); + mocks.user.getAdmin.mockResolvedValue(userStub.user1); + mocks.user.create.mockResolvedValue(userStub.user1); + mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: 'abc' }); + mocks.session.create.mockResolvedValue(factory.session()); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( oauthResponse, ); - expect(userMock.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); + expect(mocks.user.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); }); it('should ignore a negative quota', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); - userMock.getByEmail.mockResolvedValue(void 0); - userMock.getAdmin.mockResolvedValue(userStub.user1); - userMock.create.mockResolvedValue(userStub.user1); - oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: -5 }); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); + mocks.user.getByEmail.mockResolvedValue(void 0); + mocks.user.getAdmin.mockResolvedValue(userStub.user1); + mocks.user.create.mockResolvedValue(userStub.user1); + mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: -5 }); + mocks.session.create.mockResolvedValue(factory.session()); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( oauthResponse, ); - expect(userMock.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); + expect(mocks.user.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); }); it('should not set quota for 0 quota', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); - userMock.getByEmail.mockResolvedValue(void 0); - userMock.getAdmin.mockResolvedValue(userStub.user1); - userMock.create.mockResolvedValue(userStub.user1); - oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 0 }); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); + mocks.user.getByEmail.mockResolvedValue(void 0); + mocks.user.getAdmin.mockResolvedValue(userStub.user1); + mocks.user.create.mockResolvedValue(userStub.user1); + mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: 0 }); + mocks.session.create.mockResolvedValue(factory.session()); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( oauthResponse, ); - expect(userMock.create).toHaveBeenCalledWith({ + expect(mocks.user.create).toHaveBeenCalledWith({ email, name: ' ', oauthId: sub, @@ -617,17 +646,18 @@ describe('AuthService', () => { }); it('should use a valid storage quota', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); - userMock.getByEmail.mockResolvedValue(void 0); - userMock.getAdmin.mockResolvedValue(userStub.user1); - userMock.create.mockResolvedValue(userStub.user1); - oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 5 }); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); + mocks.user.getByEmail.mockResolvedValue(void 0); + mocks.user.getAdmin.mockResolvedValue(userStub.user1); + mocks.user.create.mockResolvedValue(userStub.user1); + mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: 5 }); + mocks.session.create.mockResolvedValue(factory.session()); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( oauthResponse, ); - expect(userMock.create).toHaveBeenCalledWith({ + expect(mocks.user.create).toHaveBeenCalledWith({ email, name: ' ', oauthId: sub, @@ -639,34 +669,46 @@ describe('AuthService', () => { describe('link', () => { it('should link an account', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.enabled); - userMock.update.mockResolvedValue(userStub.user1); + const authUser = factory.authUser(); + const authApiKey = factory.authApiKey({ permissions: [] }); + const auth = { user: authUser, apiKey: authApiKey }; - await sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' }); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); + mocks.user.update.mockResolvedValue(userStub.user1); - expect(userMock.update).toHaveBeenCalledWith(authStub.user1.user.id, { oauthId: sub }); + await sut.link(auth, { url: 'http://immich/user-settings?code=abc123' }); + + expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: sub }); }); it('should not link an already linked oauth.sub', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.enabled); - userMock.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity); + const authUser = factory.authUser(); + const authApiKey = factory.authApiKey({ permissions: [] }); + const auth = { user: authUser, apiKey: authApiKey }; - await expect(sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf( + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); + mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity); + + await expect(sut.link(auth, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf( BadRequestException, ); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); }); describe('unlink', () => { it('should unlink an account', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.enabled); - userMock.update.mockResolvedValue(userStub.user1); + const authUser = factory.authUser(); + const authApiKey = factory.authApiKey({ permissions: [] }); + const auth = { user: authUser, apiKey: authApiKey }; - await sut.unlink(authStub.user1); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); + mocks.user.update.mockResolvedValue(userStub.user1); - expect(userMock.update).toHaveBeenCalledWith(authStub.user1.user.id, { oauthId: '' }); + await sut.unlink(auth); + + expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' }); }); }); }); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index f46eb93111..235f20e705 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; import { isString } from 'class-validator'; -import cookieParser from 'cookie'; +import { parse } from 'cookie'; import { DateTime } from 'luxon'; import { IncomingHttpHeaders } from 'node:http'; import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants'; @@ -21,7 +21,6 @@ import { UserEntity } from 'src/entities/user.entity'; import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, Permission } from 'src/enum'; import { OAuthProfile } from 'src/repositories/oauth.repository'; import { BaseService } from 'src/services/base.service'; -import { AuthApiKey } from 'src/types'; import { isGranted } from 'src/utils/access'; import { HumanReadableSize } from 'src/utils/bytes'; @@ -288,7 +287,7 @@ export class AuthService extends BaseService { } private getCookieToken(headers: IncomingHttpHeaders): string | null { - const cookies = cookieParser.parse(headers.cookie || ''); + const cookies = parse(headers.cookie || ''); return cookies[ImmichCookie.ACCESS_TOKEN] || null; } @@ -297,22 +296,22 @@ export class AuthService extends BaseService { const bytes = Buffer.from(key, key.length === 100 ? 'hex' : 'base64url'); const sharedLink = await this.sharedLinkRepository.getByKey(bytes); - if (sharedLink && (!sharedLink.expiresAt || new Date(sharedLink.expiresAt) > new Date())) { - const user = sharedLink.user; - if (user) { - return { user, sharedLink }; - } + if (sharedLink?.user && (!sharedLink.expiresAt || new Date(sharedLink.expiresAt) > new Date())) { + return { + user: sharedLink.user, + sharedLink, + }; } throw new UnauthorizedException('Invalid share key'); } private async validateApiKey(key: string): Promise { const hashedKey = this.cryptoRepository.hashSha256(key); - const apiKey = await this.keyRepository.getKey(hashedKey); - if (apiKey) { + const apiKey = await this.apiKeyRepository.getKey(hashedKey); + if (apiKey?.user) { return { - user: apiKey.user as unknown as UserEntity, - apiKey: apiKey as unknown as AuthApiKey, + user: apiKey.user, + apiKey, }; } @@ -329,7 +328,6 @@ export class AuthService extends BaseService { private async validateSession(tokenValue: string): Promise { const hashedToken = this.cryptoRepository.hashSha256(tokenValue); const session = await this.sessionRepository.getByToken(hashedToken); - if (session?.user) { const now = DateTime.now(); const updatedAt = DateTime.fromJSDate(session.updatedAt); @@ -338,7 +336,10 @@ export class AuthService extends BaseService { await this.sessionRepository.update(session.id, { id: session.id, updatedAt: new Date() }); } - return { user: session.user, session }; + return { + user: session.user, + session, + }; } throw new UnauthorizedException('Invalid user token'); diff --git a/server/src/services/backup.service.spec.ts b/server/src/services/backup.service.spec.ts index 33d77a59aa..704087ab05 100644 --- a/server/src/services/backup.service.spec.ts +++ b/server/src/services/backup.service.spec.ts @@ -1,30 +1,18 @@ import { PassThrough } from 'node:stream'; import { defaults, SystemConfig } from 'src/config'; import { StorageCore } from 'src/cores/storage.core'; -import { ImmichWorker, StorageFolder } from 'src/enum'; -import { IDatabaseRepository } from 'src/interfaces/database.interface'; -import { JobStatus } from 'src/interfaces/job.interface'; -import { IProcessRepository } from 'src/interfaces/process.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { ImmichWorker, JobStatus, StorageFolder } from 'src/enum'; import { BackupService } from 'src/services/backup.service'; -import { IConfigRepository, ICronRepository } from 'src/types'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { mockSpawn, newTestService } from 'test/utils'; -import { describe, Mocked } from 'vitest'; +import { mockSpawn, newTestService, ServiceMocks } from 'test/utils'; +import { describe } from 'vitest'; describe(BackupService.name, () => { let sut: BackupService; - - let databaseMock: Mocked; - let configMock: Mocked; - let cronMock: Mocked; - let processMock: Mocked; - let storageMock: Mocked; - let systemMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, cronMock, configMock, databaseMock, processMock, storageMock, systemMock } = newTestService(BackupService)); + ({ sut, mocks } = newTestService(BackupService)); }); it('should work', () => { @@ -33,36 +21,41 @@ describe(BackupService.name, () => { describe('onBootstrapEvent', () => { it('should init cron job and handle config changes', async () => { - databaseMock.tryLock.mockResolvedValue(true); + mocks.database.tryLock.mockResolvedValue(true); + mocks.cron.create.mockResolvedValue(); await sut.onConfigInit({ newConfig: systemConfigStub.backupEnabled as SystemConfig }); - expect(cronMock.create).toHaveBeenCalled(); + expect(mocks.cron.create).toHaveBeenCalled(); }); it('should not initialize backup database cron job when lock is taken', async () => { - databaseMock.tryLock.mockResolvedValue(false); + mocks.database.tryLock.mockResolvedValue(false); await sut.onConfigInit({ newConfig: systemConfigStub.backupEnabled as SystemConfig }); - expect(cronMock.create).not.toHaveBeenCalled(); + expect(mocks.cron.create).not.toHaveBeenCalled(); }); it('should not initialise backup database job when running on microservices', async () => { - configMock.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); + mocks.config.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); await sut.onConfigInit({ newConfig: systemConfigStub.backupEnabled as SystemConfig }); - expect(cronMock.create).not.toHaveBeenCalled(); + expect(mocks.cron.create).not.toHaveBeenCalled(); }); }); describe('onConfigUpdateEvent', () => { beforeEach(async () => { - databaseMock.tryLock.mockResolvedValue(true); + mocks.database.tryLock.mockResolvedValue(true); + mocks.cron.create.mockResolvedValue(); + await sut.onConfigInit({ newConfig: defaults }); }); it('should update cron job if backup is enabled', () => { + mocks.cron.update.mockResolvedValue(); + sut.onConfigUpdate({ oldConfig: defaults, newConfig: { @@ -75,66 +68,66 @@ describe(BackupService.name, () => { } as SystemConfig, }); - expect(cronMock.update).toHaveBeenCalledWith({ name: 'backupDatabase', expression: '0 1 * * *', start: true }); - expect(cronMock.update).toHaveBeenCalled(); + expect(mocks.cron.update).toHaveBeenCalledWith({ name: 'backupDatabase', expression: '0 1 * * *', start: true }); + expect(mocks.cron.update).toHaveBeenCalled(); }); it('should do nothing if instance does not have the backup database lock', async () => { - databaseMock.tryLock.mockResolvedValue(false); + mocks.database.tryLock.mockResolvedValue(false); await sut.onConfigInit({ newConfig: defaults }); sut.onConfigUpdate({ newConfig: systemConfigStub.backupEnabled as SystemConfig, oldConfig: defaults }); - expect(cronMock.update).not.toHaveBeenCalled(); + expect(mocks.cron.update).not.toHaveBeenCalled(); }); }); describe('cleanupDatabaseBackups', () => { it('should do nothing if not reached keepLastAmount', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled); - storageMock.readdir.mockResolvedValue(['immich-db-backup-1.sql.gz']); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled); + mocks.storage.readdir.mockResolvedValue(['immich-db-backup-1.sql.gz']); await sut.cleanupDatabaseBackups(); - expect(storageMock.unlink).not.toHaveBeenCalled(); + expect(mocks.storage.unlink).not.toHaveBeenCalled(); }); it('should remove failed backup files', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled); - storageMock.readdir.mockResolvedValue([ + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled); + mocks.storage.readdir.mockResolvedValue([ 'immich-db-backup-123.sql.gz.tmp', 'immich-db-backup-234.sql.gz', 'immich-db-backup-345.sql.gz.tmp', ]); await sut.cleanupDatabaseBackups(); - expect(storageMock.unlink).toHaveBeenCalledTimes(2); - expect(storageMock.unlink).toHaveBeenCalledWith( + expect(mocks.storage.unlink).toHaveBeenCalledTimes(2); + expect(mocks.storage.unlink).toHaveBeenCalledWith( `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-123.sql.gz.tmp`, ); - expect(storageMock.unlink).toHaveBeenCalledWith( + expect(mocks.storage.unlink).toHaveBeenCalledWith( `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-345.sql.gz.tmp`, ); }); it('should remove old backup files over keepLastAmount', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled); - storageMock.readdir.mockResolvedValue(['immich-db-backup-1.sql.gz', 'immich-db-backup-2.sql.gz']); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled); + mocks.storage.readdir.mockResolvedValue(['immich-db-backup-1.sql.gz', 'immich-db-backup-2.sql.gz']); await sut.cleanupDatabaseBackups(); - expect(storageMock.unlink).toHaveBeenCalledTimes(1); - expect(storageMock.unlink).toHaveBeenCalledWith( + expect(mocks.storage.unlink).toHaveBeenCalledTimes(1); + expect(mocks.storage.unlink).toHaveBeenCalledWith( `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-1.sql.gz`, ); }); it('should remove old backup files over keepLastAmount and failed backups', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled); - storageMock.readdir.mockResolvedValue([ + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled); + mocks.storage.readdir.mockResolvedValue([ 'immich-db-backup-1.sql.gz.tmp', 'immich-db-backup-2.sql.gz', 'immich-db-backup-3.sql.gz', ]); await sut.cleanupDatabaseBackups(); - expect(storageMock.unlink).toHaveBeenCalledTimes(2); - expect(storageMock.unlink).toHaveBeenCalledWith( + expect(mocks.storage.unlink).toHaveBeenCalledTimes(2); + expect(mocks.storage.unlink).toHaveBeenCalledWith( `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-1.sql.gz.tmp`, ); - expect(storageMock.unlink).toHaveBeenCalledWith( + expect(mocks.storage.unlink).toHaveBeenCalledWith( `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-2.sql.gz`, ); }); @@ -142,57 +135,57 @@ describe(BackupService.name, () => { describe('handleBackupDatabase', () => { beforeEach(() => { - storageMock.readdir.mockResolvedValue([]); - processMock.spawn.mockReturnValue(mockSpawn(0, 'data', '')); - storageMock.rename.mockResolvedValue(); - storageMock.unlink.mockResolvedValue(); - systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled); - storageMock.createWriteStream.mockReturnValue(new PassThrough()); + mocks.storage.readdir.mockResolvedValue([]); + mocks.process.spawn.mockReturnValue(mockSpawn(0, 'data', '')); + mocks.storage.rename.mockResolvedValue(); + mocks.storage.unlink.mockResolvedValue(); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled); + mocks.storage.createWriteStream.mockReturnValue(new PassThrough()); }); it('should run a database backup successfully', async () => { const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.SUCCESS); - expect(storageMock.createWriteStream).toHaveBeenCalled(); + expect(mocks.storage.createWriteStream).toHaveBeenCalled(); }); it('should rename file on success', async () => { const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.SUCCESS); - expect(storageMock.rename).toHaveBeenCalled(); + expect(mocks.storage.rename).toHaveBeenCalled(); }); it('should fail if pg_dumpall fails', async () => { - processMock.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); + mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.FAILED); }); it('should not rename file if pgdump fails and gzip succeeds', async () => { - processMock.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); + mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.FAILED); - expect(storageMock.rename).not.toHaveBeenCalled(); + expect(mocks.storage.rename).not.toHaveBeenCalled(); }); it('should fail if gzip fails', async () => { - processMock.spawn.mockReturnValueOnce(mockSpawn(0, 'data', '')); - processMock.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); + mocks.process.spawn.mockReturnValueOnce(mockSpawn(0, 'data', '')); + mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.FAILED); }); it('should fail if write stream fails', async () => { - storageMock.createWriteStream.mockImplementation(() => { + mocks.storage.createWriteStream.mockImplementation(() => { throw new Error('error'); }); const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.FAILED); }); it('should fail if rename fails', async () => { - storageMock.rename.mockRejectedValue(new Error('error')); + mocks.storage.rename.mockRejectedValue(new Error('error')); const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.FAILED); }); it('should ignore unlink failing and still return failed job status', async () => { - processMock.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); - storageMock.unlink.mockRejectedValue(new Error('error')); + mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); + mocks.storage.unlink.mockRejectedValue(new Error('error')); const result = await sut.handleBackupDatabase(); - expect(storageMock.unlink).toHaveBeenCalled(); + expect(mocks.storage.unlink).toHaveBeenCalled(); expect(result).toBe(JobStatus.FAILED); }); it.each` @@ -206,9 +199,9 @@ describe(BackupService.name, () => { `( `should use pg_dumpall $expectedVersion with postgres version $postgresVersion`, async ({ postgresVersion, expectedVersion }) => { - databaseMock.getPostgresVersion.mockResolvedValue(postgresVersion); + mocks.database.getPostgresVersion.mockResolvedValue(postgresVersion); await sut.handleBackupDatabase(); - expect(processMock.spawn).toHaveBeenCalledWith( + expect(mocks.process.spawn).toHaveBeenCalledWith( `/usr/lib/postgresql/${expectedVersion}/bin/pg_dumpall`, expect.any(Array), expect.any(Object), @@ -220,9 +213,9 @@ describe(BackupService.name, () => { ${'13.99.99'} ${'18.0.0'} `(`should fail if postgres version $postgresVersion is not supported`, async ({ postgresVersion }) => { - databaseMock.getPostgresVersion.mockResolvedValue(postgresVersion); + mocks.database.getPostgresVersion.mockResolvedValue(postgresVersion); const result = await sut.handleBackupDatabase(); - expect(processMock.spawn).not.toHaveBeenCalled(); + expect(mocks.process.spawn).not.toHaveBeenCalled(); expect(result).toBe(JobStatus.FAILED); }); }); diff --git a/server/src/services/backup.service.ts b/server/src/services/backup.service.ts index 324bc4cc13..e4fe791b19 100644 --- a/server/src/services/backup.service.ts +++ b/server/src/services/backup.service.ts @@ -3,10 +3,8 @@ import { default as path } from 'node:path'; import semver from 'semver'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent, OnJob } from 'src/decorators'; -import { ImmichWorker, StorageFolder } from 'src/enum'; -import { DatabaseLock } from 'src/interfaces/database.interface'; -import { ArgOf } from 'src/interfaces/event.interface'; -import { JobName, JobStatus, QueueName } from 'src/interfaces/job.interface'; +import { DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName, StorageFolder } from 'src/enum'; +import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; import { handlePromiseError } from 'src/utils/misc'; diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 865a16a9da..f8c995c007 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Inject } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { Insertable } from 'kysely'; import sanitize from 'sanitize-filename'; import { SystemConfig } from 'src/config'; @@ -6,48 +6,51 @@ import { SALT_ROUNDS } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { Users } from 'src/db'; import { UserEntity } from 'src/entities/user.entity'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IDatabaseRepository } from 'src/interfaces/database.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { IJobRepository } from 'src/interfaces/job.interface'; -import { ILibraryRepository } from 'src/interfaces/library.interface'; -import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; -import { IMoveRepository } from 'src/interfaces/move.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { IProcessRepository } from 'src/interfaces/process.interface'; -import { ISearchRepository } from 'src/interfaces/search.interface'; -import { ISessionRepository } from 'src/interfaces/session.interface'; -import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; -import { IStackRepository } from 'src/interfaces/stack.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { ITagRepository } from 'src/interfaces/tag.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; +import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; import { AuditRepository } from 'src/repositories/audit.repository'; 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 { DownloadRepository } from 'src/repositories/download.repository'; +import { EventRepository } from 'src/repositories/event.repository'; +import { JobRepository } from 'src/repositories/job.repository'; +import { LibraryRepository } from 'src/repositories/library.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; +import { MachineLearningRepository } from 'src/repositories/machine-learning.repository'; import { MapRepository } from 'src/repositories/map.repository'; import { MediaRepository } from 'src/repositories/media.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; +import { MoveRepository } from 'src/repositories/move.repository'; import { NotificationRepository } from 'src/repositories/notification.repository'; import { OAuthRepository } from 'src/repositories/oauth.repository'; +import { PartnerRepository } from 'src/repositories/partner.repository'; +import { PersonRepository } from 'src/repositories/person.repository'; +import { ProcessRepository } from 'src/repositories/process.repository'; +import { SearchRepository } from 'src/repositories/search.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; +import { SessionRepository } from 'src/repositories/session.repository'; +import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; +import { StackRepository } from 'src/repositories/stack.repository'; +import { StorageRepository } from 'src/repositories/storage.repository'; +import { SyncRepository } from 'src/repositories/sync.repository'; +import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; +import { TagRepository } from 'src/repositories/tag.repository'; import { TelemetryRepository } from 'src/repositories/telemetry.repository'; import { TrashRepository } from 'src/repositories/trash.repository'; +import { UserRepository } from 'src/repositories/user.repository'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; import { ViewRepository } from 'src/repositories/view-repository'; import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access'; import { getConfig, updateConfig } from 'src/utils/config'; +@Injectable() export class BaseService { protected storageCore: StorageCore; @@ -55,40 +58,42 @@ export class BaseService { protected logger: LoggingRepository, protected accessRepository: AccessRepository, protected activityRepository: ActivityRepository, - protected auditRepository: AuditRepository, - @Inject(IAlbumRepository) protected albumRepository: IAlbumRepository, + protected albumRepository: AlbumRepository, protected albumUserRepository: AlbumUserRepository, - @Inject(IAssetRepository) protected assetRepository: IAssetRepository, + protected apiKeyRepository: ApiKeyRepository, + protected assetRepository: AssetRepository, + protected auditRepository: AuditRepository, protected configRepository: ConfigRepository, protected cronRepository: CronRepository, - @Inject(ICryptoRepository) protected cryptoRepository: ICryptoRepository, - @Inject(IDatabaseRepository) protected databaseRepository: IDatabaseRepository, - @Inject(IEventRepository) protected eventRepository: IEventRepository, - @Inject(IJobRepository) protected jobRepository: IJobRepository, - protected keyRepository: ApiKeyRepository, - @Inject(ILibraryRepository) protected libraryRepository: ILibraryRepository, - @Inject(IMachineLearningRepository) protected machineLearningRepository: IMachineLearningRepository, + protected cryptoRepository: CryptoRepository, + protected databaseRepository: DatabaseRepository, + protected downloadRepository: DownloadRepository, + protected eventRepository: EventRepository, + protected jobRepository: JobRepository, + protected libraryRepository: LibraryRepository, + protected machineLearningRepository: MachineLearningRepository, protected mapRepository: MapRepository, protected mediaRepository: MediaRepository, protected memoryRepository: MemoryRepository, protected metadataRepository: MetadataRepository, - @Inject(IMoveRepository) protected moveRepository: IMoveRepository, + protected moveRepository: MoveRepository, protected notificationRepository: NotificationRepository, protected oauthRepository: OAuthRepository, - @Inject(IPartnerRepository) protected partnerRepository: IPartnerRepository, - @Inject(IPersonRepository) protected personRepository: IPersonRepository, - @Inject(IProcessRepository) protected processRepository: IProcessRepository, - @Inject(ISearchRepository) protected searchRepository: ISearchRepository, + protected partnerRepository: PartnerRepository, + protected personRepository: PersonRepository, + protected processRepository: ProcessRepository, + protected searchRepository: SearchRepository, protected serverInfoRepository: ServerInfoRepository, - @Inject(ISessionRepository) protected sessionRepository: ISessionRepository, - @Inject(ISharedLinkRepository) protected sharedLinkRepository: ISharedLinkRepository, - @Inject(IStackRepository) protected stackRepository: IStackRepository, - @Inject(IStorageRepository) protected storageRepository: IStorageRepository, - @Inject(ISystemMetadataRepository) protected systemMetadataRepository: ISystemMetadataRepository, - @Inject(ITagRepository) protected tagRepository: ITagRepository, + protected sessionRepository: SessionRepository, + protected sharedLinkRepository: SharedLinkRepository, + protected stackRepository: StackRepository, + protected storageRepository: StorageRepository, + protected syncRepository: SyncRepository, + protected systemMetadataRepository: SystemMetadataRepository, + protected tagRepository: TagRepository, protected telemetryRepository: TelemetryRepository, protected trashRepository: TrashRepository, - @Inject(IUserRepository) protected userRepository: IUserRepository, + protected userRepository: UserRepository, protected versionRepository: VersionHistoryRepository, protected viewRepository: ViewRepository, ) { diff --git a/server/src/services/cli.service.spec.ts b/server/src/services/cli.service.spec.ts index 149b030e50..ce591a7e62 100644 --- a/server/src/services/cli.service.spec.ts +++ b/server/src/services/cli.service.spec.ts @@ -1,31 +1,27 @@ -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; import { CliService } from 'src/services/cli.service'; import { userStub } from 'test/fixtures/user.stub'; -import { newTestService } from 'test/utils'; -import { Mocked, describe, it } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; +import { describe, it } from 'vitest'; describe(CliService.name, () => { let sut: CliService; - - let userMock: Mocked; - let systemMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, userMock, systemMock } = newTestService(CliService)); + ({ sut, mocks } = newTestService(CliService)); }); describe('listUsers', () => { it('should list users', async () => { - userMock.getList.mockResolvedValue([userStub.admin]); + mocks.user.getList.mockResolvedValue([userStub.admin]); await expect(sut.listUsers()).resolves.toEqual([expect.objectContaining({ isAdmin: true })]); - expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: true }); + expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: true }); }); }); describe('resetAdminPassword', () => { it('should only work when there is an admin account', async () => { - userMock.getAdmin.mockResolvedValue(void 0); + mocks.user.getAdmin.mockResolvedValue(void 0); const ask = vitest.fn().mockResolvedValue('new-password'); await expect(sut.resetAdminPassword(ask)).rejects.toThrowError('Admin account does not exist'); @@ -34,12 +30,14 @@ describe(CliService.name, () => { }); it('should default to a random password', async () => { - userMock.getAdmin.mockResolvedValue(userStub.admin); + mocks.user.getAdmin.mockResolvedValue(userStub.admin); + mocks.user.update.mockResolvedValue(userStub.admin); + const ask = vitest.fn().mockImplementation(() => {}); const response = await sut.resetAdminPassword(ask); - const [id, update] = userMock.update.mock.calls[0]; + const [id, update] = mocks.user.update.mock.calls[0]; expect(response.provided).toBe(false); expect(ask).toHaveBeenCalled(); @@ -48,12 +46,14 @@ describe(CliService.name, () => { }); it('should use the supplied password', async () => { - userMock.getAdmin.mockResolvedValue(userStub.admin); + mocks.user.getAdmin.mockResolvedValue(userStub.admin); + mocks.user.update.mockResolvedValue(userStub.admin); + const ask = vitest.fn().mockResolvedValue('new-password'); const response = await sut.resetAdminPassword(ask); - const [id, update] = userMock.update.mock.calls[0]; + const [id, update] = mocks.user.update.mock.calls[0]; expect(response.provided).toBe(true); expect(ask).toHaveBeenCalled(); @@ -65,28 +65,28 @@ describe(CliService.name, () => { describe('disablePasswordLogin', () => { it('should disable password login', async () => { await sut.disablePasswordLogin(); - expect(systemMock.set).toHaveBeenCalledWith('system-config', { passwordLogin: { enabled: false } }); + expect(mocks.systemMetadata.set).toHaveBeenCalledWith('system-config', { passwordLogin: { enabled: false } }); }); }); describe('enablePasswordLogin', () => { it('should enable password login', async () => { await sut.enablePasswordLogin(); - expect(systemMock.set).toHaveBeenCalledWith('system-config', {}); + expect(mocks.systemMetadata.set).toHaveBeenCalledWith('system-config', {}); }); }); describe('disableOAuthLogin', () => { it('should disable oauth login', async () => { await sut.disableOAuthLogin(); - expect(systemMock.set).toHaveBeenCalledWith('system-config', {}); + expect(mocks.systemMetadata.set).toHaveBeenCalledWith('system-config', {}); }); }); describe('enableOAuthLogin', () => { it('should enable oauth login', async () => { await sut.enableOAuthLogin(); - expect(systemMock.set).toHaveBeenCalledWith('system-config', { oauth: { enabled: true } }); + expect(mocks.systemMetadata.set).toHaveBeenCalledWith('system-config', { oauth: { enabled: true } }); }); }); }); diff --git a/server/src/services/database.service.spec.ts b/server/src/services/database.service.spec.ts index 477cb6931f..4e45ec3ae0 100644 --- a/server/src/services/database.service.spec.ts +++ b/server/src/services/database.service.spec.ts @@ -1,22 +1,14 @@ -import { PostgresJSDialect } from 'kysely-postgres-js'; -import { - DatabaseExtension, - EXTENSION_NAMES, - IDatabaseRepository, - VectorExtension, -} from 'src/interfaces/database.interface'; +import { EXTENSION_NAMES } from 'src/constants'; +import { DatabaseExtension } from 'src/enum'; import { DatabaseService } from 'src/services/database.service'; -import { IConfigRepository, ILoggingRepository } from 'src/types'; +import { VectorExtension } from 'src/types'; import { mockEnvData } from 'test/repositories/config.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(DatabaseService.name, () => { let sut: DatabaseService; + let mocks: ServiceMocks; - let configMock: Mocked; - let databaseMock: Mocked; - let loggerMock: Mocked; let extensionRange: string; let versionBelowRange: string; let minVersionInRange: string; @@ -24,16 +16,16 @@ describe(DatabaseService.name, () => { let versionAboveRange: string; beforeEach(() => { - ({ sut, configMock, databaseMock, loggerMock } = newTestService(DatabaseService)); + ({ sut, mocks } = newTestService(DatabaseService)); extensionRange = '0.2.x'; - databaseMock.getExtensionVersionRange.mockReturnValue(extensionRange); + mocks.database.getExtensionVersionRange.mockReturnValue(extensionRange); versionBelowRange = '0.1.0'; minVersionInRange = '0.2.0'; updateInRange = '0.2.1'; versionAboveRange = '0.3.0'; - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ installedVersion: minVersionInRange, availableVersion: minVersionInRange, }); @@ -45,11 +37,11 @@ describe(DatabaseService.name, () => { describe('onBootstrap', () => { it('should throw an error if PostgreSQL version is below minimum supported version', async () => { - databaseMock.getPostgresVersion.mockResolvedValueOnce('13.10.0'); + mocks.database.getPostgresVersion.mockResolvedValueOnce('13.10.0'); await expect(sut.onBootstrap()).rejects.toThrow('Invalid PostgreSQL version. Found 13.10.0'); - expect(databaseMock.getPostgresVersion).toHaveBeenCalledTimes(1); + expect(mocks.database.getPostgresVersion).toHaveBeenCalledTimes(1); }); describe.each(>[ @@ -57,13 +49,16 @@ describe(DatabaseService.name, () => { { extension: DatabaseExtension.VECTORS, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORS] }, ])('should work with $extensionName', ({ extension, extensionName }) => { beforeEach(() => { - configMock.getEnv.mockReturnValue( + mocks.config.getEnv.mockReturnValue( mockEnvData({ database: { config: { kysely: { - dialect: expect.any(PostgresJSDialect), - log: ['error'], + host: 'database', + port: 5432, + user: 'postgres', + password: 'postgres', + database: 'immich', }, typeorm: { connectionType: 'parts', @@ -83,34 +78,34 @@ describe(DatabaseService.name, () => { }); it(`should start up successfully with ${extension}`, async () => { - databaseMock.getPostgresVersion.mockResolvedValue('14.0.0'); - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getPostgresVersion.mockResolvedValue('14.0.0'); + mocks.database.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: minVersionInRange, }); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(databaseMock.getPostgresVersion).toHaveBeenCalled(); - expect(databaseMock.createExtension).toHaveBeenCalledWith(extension); - expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.getExtensionVersion).toHaveBeenCalled(); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.database.getPostgresVersion).toHaveBeenCalled(); + expect(mocks.database.createExtension).toHaveBeenCalledWith(extension); + expect(mocks.database.createExtension).toHaveBeenCalledTimes(1); + expect(mocks.database.getExtensionVersion).toHaveBeenCalled(); + expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); it(`should throw an error if the ${extension} extension is not installed`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: null }); + mocks.database.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: null }); const message = `The ${extensionName} extension is not available in this Postgres instance. If using a container image, ensure the image has the extension installed.`; await expect(sut.onBootstrap()).rejects.toThrow(message); - expect(databaseMock.createExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(mocks.database.createExtension).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); }); it(`should throw an error if the ${extension} extension version is below minimum supported version`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ installedVersion: versionBelowRange, availableVersion: versionBelowRange, }); @@ -119,80 +114,80 @@ describe(DatabaseService.name, () => { `The ${extensionName} extension version is ${versionBelowRange}, but Immich only supports ${extensionRange}`, ); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); }); it(`should throw an error if ${extension} extension version is a nightly`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: '0.0.0', availableVersion: '0.0.0' }); + mocks.database.getExtensionVersion.mockResolvedValue({ installedVersion: '0.0.0', availableVersion: '0.0.0' }); await expect(sut.onBootstrap()).rejects.toThrow( `The ${extensionName} extension version is 0.0.0, which means it is a nightly release.`, ); - expect(databaseMock.createExtension).not.toHaveBeenCalled(); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(mocks.database.createExtension).not.toHaveBeenCalled(); + expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); }); it(`should do in-range update for ${extension} extension`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ availableVersion: updateInRange, installedVersion: minVersionInRange, }); - databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); + mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: false }); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.getExtensionVersion).toHaveBeenCalled(); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.database.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); + expect(mocks.database.updateVectorExtension).toHaveBeenCalledTimes(1); + expect(mocks.database.getExtensionVersion).toHaveBeenCalled(); + expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); it(`should not upgrade ${extension} if same version`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ availableVersion: minVersionInRange, installedVersion: minVersionInRange, }); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); it(`should throw error if ${extension} available version is below range`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ availableVersion: versionBelowRange, installedVersion: null, }); await expect(sut.onBootstrap()).rejects.toThrow(); - expect(databaseMock.createExtension).not.toHaveBeenCalled(); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.database.createExtension).not.toHaveBeenCalled(); + expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); it(`should throw error if ${extension} available version is above range`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ availableVersion: versionAboveRange, installedVersion: minVersionInRange, }); await expect(sut.onBootstrap()).rejects.toThrow(); - expect(databaseMock.createExtension).not.toHaveBeenCalled(); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.database.createExtension).not.toHaveBeenCalled(); + expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); it('should throw error if available version is below installed version', async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ availableVersion: minVersionInRange, installedVersion: updateInRange, }); @@ -201,13 +196,13 @@ describe(DatabaseService.name, () => { `The database currently has ${extensionName} ${updateInRange} activated, but the Postgres instance only has ${minVersionInRange} available.`, ); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); it('should throw error if installed version is not in version range', async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ availableVersion: minVersionInRange, installedVersion: versionAboveRange, }); @@ -216,90 +211,93 @@ describe(DatabaseService.name, () => { `The ${extensionName} extension version is ${versionAboveRange}, but Immich only supports`, ); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); it(`should raise error if ${extension} extension upgrade failed`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ availableVersion: updateInRange, installedVersion: minVersionInRange, }); - databaseMock.updateVectorExtension.mockRejectedValue(new Error('Failed to update extension')); + mocks.database.updateVectorExtension.mockRejectedValue(new Error('Failed to update extension')); await expect(sut.onBootstrap()).rejects.toThrow('Failed to update extension'); - expect(loggerMock.warn.mock.calls[0][0]).toContain( + expect(mocks.logger.warn.mock.calls[0][0]).toContain( `The ${extensionName} extension can be updated to ${updateInRange}.`, ); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); + expect(mocks.database.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); }); it(`should warn if ${extension} extension update requires restart`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ availableVersion: updateInRange, installedVersion: minVersionInRange, }); - databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: true }); + mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: true }); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(loggerMock.warn).toHaveBeenCalledTimes(1); - expect(loggerMock.warn.mock.calls[0][0]).toContain(extensionName); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.logger.warn).toHaveBeenCalledTimes(1); + expect(mocks.logger.warn.mock.calls[0][0]).toContain(extensionName); + expect(mocks.database.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); + expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); it(`should reindex ${extension} indices if needed`, async () => { - databaseMock.shouldReindex.mockResolvedValue(true); + mocks.database.shouldReindex.mockResolvedValue(true); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2); - expect(databaseMock.reindex).toHaveBeenCalledTimes(2); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.database.shouldReindex).toHaveBeenCalledTimes(2); + expect(mocks.database.reindex).toHaveBeenCalledTimes(2); + expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); it(`should throw an error if reindexing fails`, async () => { - databaseMock.shouldReindex.mockResolvedValue(true); - databaseMock.reindex.mockRejectedValue(new Error('Error reindexing')); + mocks.database.shouldReindex.mockResolvedValue(true); + mocks.database.reindex.mockRejectedValue(new Error('Error reindexing')); await expect(sut.onBootstrap()).rejects.toBeDefined(); - expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(1); - expect(databaseMock.reindex).toHaveBeenCalledTimes(1); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - expect(loggerMock.warn).toHaveBeenCalledWith( + expect(mocks.database.shouldReindex).toHaveBeenCalledTimes(1); + expect(mocks.database.reindex).toHaveBeenCalledTimes(1); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); + expect(mocks.logger.warn).toHaveBeenCalledWith( expect.stringContaining('Could not run vector reindexing checks.'), ); }); it(`should not reindex ${extension} indices if not needed`, async () => { - databaseMock.shouldReindex.mockResolvedValue(false); + mocks.database.shouldReindex.mockResolvedValue(false); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2); - expect(databaseMock.reindex).toHaveBeenCalledTimes(0); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(mocks.database.shouldReindex).toHaveBeenCalledTimes(2); + expect(mocks.database.reindex).toHaveBeenCalledTimes(0); + expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1); + expect(mocks.logger.fatal).not.toHaveBeenCalled(); }); }); it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => { - configMock.getEnv.mockReturnValue( + mocks.config.getEnv.mockReturnValue( mockEnvData({ database: { config: { kysely: { - dialect: expect.any(PostgresJSDialect), - log: ['error'], + host: 'database', + port: 5432, + user: 'postgres', + password: 'postgres', + database: 'immich', }, typeorm: { connectionType: 'parts', @@ -319,17 +317,20 @@ describe(DatabaseService.name, () => { await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); }); it(`should throw error if pgvector extension could not be created`, async () => { - configMock.getEnv.mockReturnValue( + mocks.config.getEnv.mockReturnValue( mockEnvData({ database: { config: { kysely: { - dialect: expect.any(PostgresJSDialect), - log: ['error'], + host: 'database', + port: 5432, + user: 'postgres', + password: 'postgres', + database: 'immich', }, typeorm: { connectionType: 'parts', @@ -346,87 +347,41 @@ describe(DatabaseService.name, () => { }, }), ); - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: minVersionInRange, }); - databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); - databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension')); + mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: false }); + mocks.database.createExtension.mockRejectedValue(new Error('Failed to create extension')); await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension'); - expect(loggerMock.fatal).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal.mock.calls[0][0]).toContain( + expect(mocks.logger.fatal).toHaveBeenCalledTimes(1); + expect(mocks.logger.fatal.mock.calls[0][0]).toContain( `Alternatively, if your Postgres instance has pgvecto.rs, you may use this instead`, ); - expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(mocks.database.createExtension).toHaveBeenCalledTimes(1); + expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); }); it(`should throw error if pgvecto.rs extension could not be created`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ + mocks.database.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: minVersionInRange, }); - databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); - databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension')); + mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: false }); + mocks.database.createExtension.mockRejectedValue(new Error('Failed to create extension')); await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension'); - expect(loggerMock.fatal).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal.mock.calls[0][0]).toContain( + expect(mocks.logger.fatal).toHaveBeenCalledTimes(1); + expect(mocks.logger.fatal.mock.calls[0][0]).toContain( `Alternatively, if your Postgres instance has pgvector, you may use this instead`, ); - expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - }); - }); - - describe('handleConnectionError', () => { - beforeAll(() => { - vi.useFakeTimers(); - }); - - afterAll(() => { - vi.useRealTimers(); - }); - - it('should not override interval', () => { - sut.handleConnectionError(new Error('Error')); - expect(loggerMock.error).toHaveBeenCalled(); - - sut.handleConnectionError(new Error('foo')); - expect(loggerMock.error).toHaveBeenCalledTimes(1); - }); - - it('should reconnect when interval elapses', async () => { - databaseMock.reconnect.mockResolvedValue(true); - - sut.handleConnectionError(new Error('error')); - await vi.advanceTimersByTimeAsync(5000); - - expect(databaseMock.reconnect).toHaveBeenCalledTimes(1); - expect(loggerMock.log).toHaveBeenCalledWith('Database reconnected'); - - await vi.advanceTimersByTimeAsync(5000); - expect(databaseMock.reconnect).toHaveBeenCalledTimes(1); - }); - - it('should try again when reconnection fails', async () => { - databaseMock.reconnect.mockResolvedValueOnce(false); - - sut.handleConnectionError(new Error('error')); - await vi.advanceTimersByTimeAsync(5000); - - expect(databaseMock.reconnect).toHaveBeenCalledTimes(1); - expect(loggerMock.warn).toHaveBeenCalledWith(expect.stringContaining('Database connection failed')); - - databaseMock.reconnect.mockResolvedValueOnce(true); - await vi.advanceTimersByTimeAsync(5000); - expect(databaseMock.reconnect).toHaveBeenCalledTimes(2); - expect(loggerMock.log).toHaveBeenCalledWith('Database reconnected'); + expect(mocks.database.createExtension).toHaveBeenCalledTimes(1); + expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled(); + expect(mocks.database.runMigrations).not.toHaveBeenCalled(); }); }); }); diff --git a/server/src/services/database.service.ts b/server/src/services/database.service.ts index ec0075b119..d71dc25104 100644 --- a/server/src/services/database.service.ts +++ b/server/src/services/database.service.ts @@ -1,16 +1,10 @@ import { Injectable } from '@nestjs/common'; -import { Duration } from 'luxon'; import semver from 'semver'; +import { EXTENSION_NAMES } from 'src/constants'; import { OnEvent } from 'src/decorators'; -import { - DatabaseExtension, - DatabaseLock, - EXTENSION_NAMES, - VectorExtension, - VectorIndex, -} from 'src/interfaces/database.interface'; -import { BootstrapEventPriority } from 'src/interfaces/event.interface'; +import { BootstrapEventPriority, DatabaseExtension, DatabaseLock, VectorIndex } from 'src/enum'; import { BaseService } from 'src/services/base.service'; +import { VectorExtension } from 'src/types'; type CreateFailedArgs = { name: string; extension: string; otherName: string }; type UpdateFailedArgs = { name: string; extension: string; availableVersion: string }; @@ -59,12 +53,8 @@ const messages = { If ${name} ${installedVersion} is compatible with Immich, please ensure the Postgres instance has this available.`, }; -const RETRY_DURATION = Duration.fromObject({ seconds: 5 }); - @Injectable() export class DatabaseService extends BaseService { - private reconnection?: NodeJS.Timeout; - @OnEvent({ name: 'app.bootstrap', priority: BootstrapEventPriority.DatabaseService }) async onBootstrap() { const version = await this.databaseRepository.getPostgresVersion(); @@ -113,30 +103,9 @@ export class DatabaseService extends BaseService { if (!database.skipMigrations) { await this.databaseRepository.runMigrations(); } - this.databaseRepository.init(); }); } - handleConnectionError(error: Error) { - if (this.reconnection) { - return; - } - - this.logger.error(`Database disconnected: ${error}`); - this.reconnection = setInterval(() => void this.reconnect(), RETRY_DURATION.toMillis()); - } - - private async reconnect() { - const isConnected = await this.databaseRepository.reconnect(); - if (isConnected) { - this.logger.log('Database reconnected'); - clearInterval(this.reconnection); - delete this.reconnection; - } else { - this.logger.warn(`Database connection failed, retrying in ${RETRY_DURATION.toHuman()}`); - } - } - private async createExtension(extension: DatabaseExtension) { try { await this.databaseRepository.createExtension(extension); diff --git a/server/src/services/download.service.spec.ts b/server/src/services/download.service.spec.ts index 12e3414ac3..7646637093 100644 --- a/server/src/services/download.service.spec.ts +++ b/server/src/services/download.service.spec.ts @@ -1,22 +1,17 @@ import { BadRequestException } from '@nestjs/common'; import { DownloadResponseDto } from 'src/dtos/download.dto'; -import { AssetEntity } from 'src/entities/asset.entity'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; import { DownloadService } from 'src/services/download.service'; -import { ILoggingRepository } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; +import { makeStream, newTestService, ServiceMocks } from 'test/utils'; import { Readable } from 'typeorm/platform/PlatformTools.js'; -import { Mocked, vitest } from 'vitest'; +import { vitest } from 'vitest'; const downloadResponse: DownloadResponseDto = { totalSize: 105_000, archives: [ { - assetIds: ['asset-id', 'asset-id'], + assetIds: ['asset-1', 'asset-2'], size: 105_000, }, ], @@ -24,17 +19,14 @@ const downloadResponse: DownloadResponseDto = { describe(DownloadService.name, () => { let sut: DownloadService; - let accessMock: IAccessRepositoryMock; - let assetMock: Mocked; - let loggerMock: Mocked; - let storageMock: Mocked; + let mocks: ServiceMocks; it('should work', () => { expect(sut).toBeDefined(); }); beforeEach(() => { - ({ sut, accessMock, assetMock, loggerMock, storageMock } = newTestService(DownloadService)); + ({ sut, mocks } = newTestService(DownloadService)); }); describe('downloadArchive', () => { @@ -45,9 +37,9 @@ describe(DownloadService.name, () => { stream: new Readable(), }; - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - assetMock.getByIds.mockResolvedValue([{ ...assetStub.noResizePath, id: 'asset-1' }]); - storageMock.createZipStream.mockReturnValue(archiveMock); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.noResizePath, id: 'asset-1' }]); + mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ stream: archiveMock.stream, @@ -64,19 +56,19 @@ describe(DownloadService.name, () => { stream: new Readable(), }; - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - storageMock.realpath.mockRejectedValue(new Error('Could not read file')); - assetMock.getByIds.mockResolvedValue([ + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + mocks.storage.realpath.mockRejectedValue(new Error('Could not read file')); + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.noResizePath, id: 'asset-1' }, { ...assetStub.noWebpPath, id: 'asset-2' }, ]); - storageMock.createZipStream.mockReturnValue(archiveMock); + mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ stream: archiveMock.stream, }); - expect(loggerMock.warn).toHaveBeenCalledTimes(2); + expect(mocks.logger.warn).toHaveBeenCalledTimes(2); expect(archiveMock.addFile).toHaveBeenCalledTimes(2); expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg'); expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, 'upload/library/IMG_456.jpg', 'IMG_456.jpg'); @@ -89,12 +81,12 @@ describe(DownloadService.name, () => { stream: new Readable(), }; - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - assetMock.getByIds.mockResolvedValue([ + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.noResizePath, id: 'asset-1' }, { ...assetStub.noWebpPath, id: 'asset-2' }, ]); - storageMock.createZipStream.mockReturnValue(archiveMock); + mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ stream: archiveMock.stream, @@ -112,12 +104,12 @@ describe(DownloadService.name, () => { stream: new Readable(), }; - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - assetMock.getByIds.mockResolvedValue([ + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.noResizePath, id: 'asset-1' }, { ...assetStub.noResizePath, id: 'asset-2' }, ]); - storageMock.createZipStream.mockReturnValue(archiveMock); + mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ stream: archiveMock.stream, @@ -135,12 +127,12 @@ describe(DownloadService.name, () => { stream: new Readable(), }; - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - assetMock.getByIds.mockResolvedValue([ + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.noResizePath, id: 'asset-2' }, { ...assetStub.noResizePath, id: 'asset-1' }, ]); - storageMock.createZipStream.mockReturnValue(archiveMock); + mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ stream: archiveMock.stream, @@ -158,12 +150,12 @@ describe(DownloadService.name, () => { stream: new Readable(), }; - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - assetMock.getByIds.mockResolvedValue([ + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.noResizePath, id: 'asset-1', originalPath: '/path/to/symlink.jpg' }, ]); - storageMock.realpath.mockResolvedValue('/path/to/realpath.jpg'); - storageMock.createZipStream.mockReturnValue(archiveMock); + mocks.storage.realpath.mockResolvedValue('/path/to/realpath.jpg'); + mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1'] })).resolves.toEqual({ stream: archiveMock.stream, @@ -179,53 +171,64 @@ describe(DownloadService.name, () => { }); it('should return a list of archives (assetIds)', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - assetMock.getByIds.mockResolvedValue([assetStub.image, assetStub.video]); - const assetIds = ['asset-1', 'asset-2']; + + mocks.user.getMetadata.mockResolvedValue([]); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(assetIds)); + mocks.downloadRepository.downloadAssetIds.mockReturnValue( + makeStream([ + { id: 'asset-1', livePhotoVideoId: null, size: 100_000 }, + { id: 'asset-2', livePhotoVideoId: null, size: 5000 }, + ]), + ); + await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual(downloadResponse); - expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1', 'asset-2'], { exifInfo: true }); + expect(mocks.downloadRepository.downloadAssetIds).toHaveBeenCalledWith(['asset-1', 'asset-2']); }); it('should return a list of archives (albumId)', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-1'])); - assetMock.getByAlbumId.mockResolvedValue({ - items: [assetStub.image, assetStub.video], - hasNextPage: false, - }); + mocks.user.getMetadata.mockResolvedValue([]); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-1'])); + mocks.downloadRepository.downloadAlbumId.mockReturnValue( + makeStream([ + { id: 'asset-1', livePhotoVideoId: null, size: 100_000 }, + { id: 'asset-2', livePhotoVideoId: null, size: 5000 }, + ]), + ); await expect(sut.getDownloadInfo(authStub.admin, { albumId: 'album-1' })).resolves.toEqual(downloadResponse); - expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-1'])); - expect(assetMock.getByAlbumId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, 'album-1'); + expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-1'])); + expect(mocks.downloadRepository.downloadAlbumId).toHaveBeenCalledWith('album-1'); }); it('should return a list of archives (userId)', async () => { - assetMock.getByUserId.mockResolvedValue({ - items: [assetStub.image, assetStub.video], - hasNextPage: false, - }); + mocks.user.getMetadata.mockResolvedValue([]); + mocks.downloadRepository.downloadUserId.mockReturnValue( + makeStream([ + { id: 'asset-1', livePhotoVideoId: null, size: 100_000 }, + { id: 'asset-2', livePhotoVideoId: null, size: 5000 }, + ]), + ); await expect(sut.getDownloadInfo(authStub.admin, { userId: authStub.admin.user.id })).resolves.toEqual( downloadResponse, ); - expect(assetMock.getByUserId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, authStub.admin.user.id, { - isVisible: true, - }); + expect(mocks.downloadRepository.downloadUserId).toHaveBeenCalledWith(authStub.admin.user.id); }); it('should split archives by size', async () => { - assetMock.getByUserId.mockResolvedValue({ - items: [ - { ...assetStub.image, id: 'asset-1' }, - { ...assetStub.video, id: 'asset-2' }, - { ...assetStub.withLocation, id: 'asset-3' }, - { ...assetStub.noWebpPath, id: 'asset-4' }, - ], - hasNextPage: false, - }); + mocks.user.getMetadata.mockResolvedValue([]); + mocks.downloadRepository.downloadUserId.mockReturnValue( + makeStream([ + { id: 'asset-1', livePhotoVideoId: null, size: 5000 }, + { id: 'asset-2', livePhotoVideoId: null, size: 100_000 }, + { id: 'asset-3', livePhotoVideoId: null, size: 23_456 }, + { id: 'asset-4', livePhotoVideoId: null, size: 123_000 }, + ]), + ); await expect( sut.getDownloadInfo(authStub.admin, { @@ -242,49 +245,52 @@ describe(DownloadService.name, () => { }); it('should include the video portion of a live photo', async () => { - const assetIds = [assetStub.livePhotoStillAsset.id]; - const assets = [assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]; + const assetIds = ['asset-1', 'asset-2']; - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(assetIds)); - assetMock.getByIds.mockImplementation( - (ids) => - Promise.resolve( - ids.map((id) => assets.find((asset) => asset.id === id)).filter((asset) => !!asset), - ) as Promise, + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(assetIds)); + mocks.user.getMetadata.mockResolvedValue([]); + mocks.downloadRepository.downloadAssetIds.mockReturnValue( + makeStream([ + { id: 'asset-1', livePhotoVideoId: 'asset-3', size: 5000 }, + { id: 'asset-2', livePhotoVideoId: 'asset-4', size: 100_000 }, + ]), + ); + mocks.downloadRepository.downloadMotionAssetIds.mockReturnValue( + makeStream([ + { id: 'asset-3', livePhotoVideoId: null, size: 23_456, originalPath: '/path/to/file.mp4' }, + { id: 'asset-4', livePhotoVideoId: null, size: 123_000, originalPath: '/path/to/file.mp4' }, + ]), ); - await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual({ - totalSize: 125_000, + await expect(sut.getDownloadInfo(authStub.admin, { assetIds, archiveSize: 30_000 })).resolves.toEqual({ + totalSize: 251_456, archives: [ - { - assetIds: [assetStub.livePhotoStillAsset.id, assetStub.livePhotoMotionAsset.id], - size: 125_000, - }, + { assetIds: ['asset-1', 'asset-2'], size: 105_000 }, + { assetIds: ['asset-3', 'asset-4'], size: 146_456 }, ], }); }); it('should skip the video portion of an android live photo by default', async () => { - const assetIds = [assetStub.livePhotoStillAsset.id]; - const assets = [ - assetStub.livePhotoStillAsset, - { ...assetStub.livePhotoMotionAsset, originalPath: 'upload/encoded-video/uuid-MP.mp4' }, - ]; + const assetIds = ['asset-1']; - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(assetIds)); - assetMock.getByIds.mockImplementation( - (ids) => - Promise.resolve( - ids.map((id) => assets.find((asset) => asset.id === id)).filter((asset) => !!asset), - ) as Promise, + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(assetIds)); + mocks.user.getMetadata.mockResolvedValue([]); + mocks.downloadRepository.downloadAssetIds.mockReturnValue( + makeStream([{ id: 'asset-1', livePhotoVideoId: 'asset-3', size: 5000 }]), + ); + mocks.downloadRepository.downloadMotionAssetIds.mockReturnValue( + makeStream([ + { id: 'asset-2', livePhotoVideoId: null, size: 23_456, originalPath: 'upload/encoded-video/uuid-MP.mp4' }, + ]), ); await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual({ - totalSize: 25_000, + totalSize: 5000, archives: [ { - assetIds: [assetStub.livePhotoStillAsset.id], - size: 25_000, + assetIds: ['asset-1'], + size: 5000, }, ], }); diff --git a/server/src/services/download.service.ts b/server/src/services/download.service.ts index 3d66f009cf..cb664aea32 100644 --- a/server/src/services/download.service.ts +++ b/server/src/services/download.service.ts @@ -4,52 +4,72 @@ import { StorageCore } from 'src/cores/storage.core'; import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto'; -import { AssetEntity } from 'src/entities/asset.entity'; import { Permission } from 'src/enum'; -import { ImmichReadStream } from 'src/interfaces/storage.interface'; +import { ImmichReadStream } from 'src/repositories/storage.repository'; import { BaseService } from 'src/services/base.service'; import { HumanReadableSize } from 'src/utils/bytes'; -import { usePagination } from 'src/utils/pagination'; import { getPreferences } from 'src/utils/preferences'; @Injectable() export class DownloadService extends BaseService { async getDownloadInfo(auth: AuthDto, dto: DownloadInfoDto): Promise { + let assets; + + if (dto.assetIds) { + const assetIds = dto.assetIds; + await this.requireAccess({ auth, permission: Permission.ASSET_DOWNLOAD, ids: assetIds }); + assets = this.downloadRepository.downloadAssetIds(assetIds); + } else if (dto.albumId) { + const albumId = dto.albumId; + await this.requireAccess({ auth, permission: Permission.ALBUM_DOWNLOAD, ids: [albumId] }); + assets = this.downloadRepository.downloadAlbumId(albumId); + } else if (dto.userId) { + const userId = dto.userId; + await this.requireAccess({ auth, permission: Permission.TIMELINE_DOWNLOAD, ids: [userId] }); + assets = this.downloadRepository.downloadUserId(userId); + } else { + throw new BadRequestException('assetIds, albumId, or userId is required'); + } + const targetSize = dto.archiveSize || HumanReadableSize.GiB * 4; + const metadata = await this.userRepository.getMetadata(auth.user.id); + const preferences = getPreferences(auth.user.email, metadata); + const motionIds = new Set(); const archives: DownloadArchiveInfo[] = []; let archive: DownloadArchiveInfo = { size: 0, assetIds: [] }; - const preferences = getPreferences(auth.user); + const addToArchive = ({ id, size }: { id: string; size: number | null }) => { + archive.assetIds.push(id); + archive.size += Number(size || 0); - const assetPagination = await this.getDownloadAssets(auth, dto); - for await (const assets of assetPagination) { - // motion part of live photos - const motionIds = assets.map((asset) => asset.livePhotoVideoId).filter((id): id is string => !!id); - if (motionIds.length > 0) { - const motionAssets = await this.assetRepository.getByIds(motionIds, { exifInfo: true }); - for (const motionAsset of motionAssets) { - if ( - !StorageCore.isAndroidMotionPath(motionAsset.originalPath) || - preferences.download.includeEmbeddedVideos - ) { - assets.push(motionAsset); - } - } - } - - for (const asset of assets) { - archive.size += Number(asset.exifInfo?.fileSizeInByte || 0); - archive.assetIds.push(asset.id); - - if (archive.size > targetSize) { - archives.push(archive); - archive = { size: 0, assetIds: [] }; - } - } - - if (archive.assetIds.length > 0) { + if (archive.size > targetSize) { archives.push(archive); + archive = { size: 0, assetIds: [] }; } + }; + + for await (const asset of assets) { + // motion part of live photos + if (asset.livePhotoVideoId) { + motionIds.add(asset.livePhotoVideoId); + } + + addToArchive(asset); + } + + if (motionIds.size > 0) { + const motionAssets = this.downloadRepository.downloadMotionAssetIds([...motionIds]); + for await (const motionAsset of motionAssets) { + if (StorageCore.isAndroidMotionPath(motionAsset.originalPath) && !preferences.download.includeEmbeddedVideos) { + continue; + } + + addToArchive(motionAsset); + } + } + + if (archive.assetIds.length > 0) { + archives.push(archive); } let totalSize = 0; @@ -98,31 +118,4 @@ export class DownloadService extends BaseService { return { stream: zip.stream }; } - - private async getDownloadAssets(auth: AuthDto, dto: DownloadInfoDto): Promise> { - const PAGINATION_SIZE = 2500; - - if (dto.assetIds) { - const assetIds = dto.assetIds; - await this.requireAccess({ auth, permission: Permission.ASSET_DOWNLOAD, ids: assetIds }); - const assets = await this.assetRepository.getByIds(assetIds, { exifInfo: true }); - return usePagination(PAGINATION_SIZE, () => ({ hasNextPage: false, items: assets })); - } - - if (dto.albumId) { - const albumId = dto.albumId; - await this.requireAccess({ auth, permission: Permission.ALBUM_DOWNLOAD, ids: [albumId] }); - return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByAlbumId(pagination, albumId)); - } - - if (dto.userId) { - const userId = dto.userId; - await this.requireAccess({ auth, permission: Permission.TIMELINE_DOWNLOAD, ids: [userId] }); - return usePagination(PAGINATION_SIZE, (pagination) => - this.assetRepository.getByUserId(pagination, userId, { isVisible: true }), - ); - } - - throw new BadRequestException('assetIds, albumId, or userId is required'); - } } diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index 3c332edbe9..8be943eaf0 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -1,28 +1,20 @@ -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { ISearchRepository } from 'src/interfaces/search.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { JobName, JobStatus } from 'src/enum'; +import { WithoutProperty } from 'src/repositories/asset.repository'; import { DuplicateService } from 'src/services/duplicate.service'; import { SearchService } from 'src/services/search.service'; -import { ILoggingRepository } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { newTestService } from 'test/utils'; -import { Mocked, beforeEach, vitest } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; +import { beforeEach, vitest } from 'vitest'; vitest.useFakeTimers(); describe(SearchService.name, () => { let sut: DuplicateService; - - let assetMock: Mocked; - let jobMock: Mocked; - let loggerMock: Mocked; - let searchMock: Mocked; - let systemMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, assetMock, jobMock, loggerMock, searchMock, systemMock } = newTestService(DuplicateService)); + ({ sut, mocks } = newTestService(DuplicateService)); }); it('should work', () => { @@ -31,7 +23,7 @@ describe(SearchService.name, () => { describe('getDuplicates', () => { it('should get duplicates', async () => { - assetMock.getDuplicates.mockResolvedValue([ + mocks.asset.getDuplicates.mockResolvedValue([ { duplicateId: assetStub.hasDupe.duplicateId!, assets: [assetStub.hasDupe, assetStub.hasDupe], @@ -51,7 +43,7 @@ describe(SearchService.name, () => { describe('handleQueueSearchDuplicates', () => { beforeEach(() => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { enabled: true, duplicateDetection: { @@ -62,7 +54,7 @@ describe(SearchService.name, () => { }); it('should skip if machine learning is disabled', async () => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { enabled: false, duplicateDetection: { @@ -72,13 +64,13 @@ describe(SearchService.name, () => { }); await expect(sut.handleQueueSearchDuplicates({})).resolves.toBe(JobStatus.SKIPPED); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(systemMock.get).toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).toHaveBeenCalled(); }); it('should skip if duplicate detection is disabled', async () => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { enabled: true, duplicateDetection: { @@ -88,21 +80,21 @@ describe(SearchService.name, () => { }); await expect(sut.handleQueueSearchDuplicates({})).resolves.toBe(JobStatus.SKIPPED); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(systemMock.get).toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).toHaveBeenCalled(); }); it('should queue missing assets', async () => { - assetMock.getWithout.mockResolvedValue({ + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); await sut.handleQueueSearchDuplicates({}); - expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.DUPLICATE); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.DUPLICATE); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.DUPLICATE_DETECTION, data: { id: assetStub.image.id }, @@ -111,15 +103,15 @@ describe(SearchService.name, () => { }); it('should queue all assets', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); await sut.handleQueueSearchDuplicates({ force: true }); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.DUPLICATE_DETECTION, data: { id: assetStub.image.id }, @@ -130,7 +122,7 @@ describe(SearchService.name, () => { describe('handleSearchDuplicates', () => { beforeEach(() => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { enabled: true, duplicateDetection: { @@ -141,7 +133,7 @@ describe(SearchService.name, () => { }); it('should skip if machine learning is disabled', async () => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { enabled: false, duplicateDetection: { @@ -150,7 +142,7 @@ describe(SearchService.name, () => { }, }); const id = assetStub.livePhotoMotionAsset.id; - assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); const result = await sut.handleSearchDuplicates({ id }); @@ -158,7 +150,7 @@ describe(SearchService.name, () => { }); it('should skip if duplicate detection is disabled', async () => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { enabled: true, duplicateDetection: { @@ -167,7 +159,7 @@ describe(SearchService.name, () => { }, }); const id = assetStub.livePhotoMotionAsset.id; - assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); const result = await sut.handleSearchDuplicates({ id }); @@ -178,40 +170,50 @@ describe(SearchService.name, () => { const result = await sut.handleSearchDuplicates({ id: assetStub.image.id }); expect(result).toBe(JobStatus.FAILED); - expect(loggerMock.error).toHaveBeenCalledWith(`Asset ${assetStub.image.id} not found`); + expect(mocks.logger.error).toHaveBeenCalledWith(`Asset ${assetStub.image.id} not found`); }); - it('should skip if asset is not visible', async () => { - const id = assetStub.livePhotoMotionAsset.id; - assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); + it('should skip if asset is part of stack', async () => { + const id = assetStub.primaryImage.id; + mocks.asset.getById.mockResolvedValue(assetStub.primaryImage); const result = await sut.handleSearchDuplicates({ id }); expect(result).toBe(JobStatus.SKIPPED); - expect(loggerMock.debug).toHaveBeenCalledWith(`Asset ${id} is not visible, skipping`); + expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${id} is part of a stack, skipping`); + }); + + it('should skip if asset is not visible', async () => { + const id = assetStub.livePhotoMotionAsset.id; + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); + + const result = await sut.handleSearchDuplicates({ id }); + + expect(result).toBe(JobStatus.SKIPPED); + expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${id} is not visible, skipping`); }); it('should fail if asset is missing preview image', async () => { - assetMock.getById.mockResolvedValue(assetStub.noResizePath); + mocks.asset.getById.mockResolvedValue(assetStub.noResizePath); const result = await sut.handleSearchDuplicates({ id: assetStub.noResizePath.id }); expect(result).toBe(JobStatus.FAILED); - expect(loggerMock.warn).toHaveBeenCalledWith(`Asset ${assetStub.noResizePath.id} is missing preview image`); + expect(mocks.logger.warn).toHaveBeenCalledWith(`Asset ${assetStub.noResizePath.id} is missing preview image`); }); it('should fail if asset is missing embedding', async () => { - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.asset.getById.mockResolvedValue(assetStub.image); const result = await sut.handleSearchDuplicates({ id: assetStub.image.id }); expect(result).toBe(JobStatus.FAILED); - expect(loggerMock.debug).toHaveBeenCalledWith(`Asset ${assetStub.image.id} is missing embedding`); + expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${assetStub.image.id} is missing embedding`); }); it('should search for duplicates and update asset with duplicateId', async () => { - assetMock.getById.mockResolvedValue(assetStub.hasEmbedding); - searchMock.searchDuplicates.mockResolvedValue([ + mocks.asset.getById.mockResolvedValue(assetStub.hasEmbedding); + mocks.search.searchDuplicates.mockResolvedValue([ { assetId: assetStub.image.id, distance: 0.01, duplicateId: null }, ]); const expectedAssetIds = [assetStub.image.id, assetStub.hasEmbedding.id]; @@ -219,58 +221,58 @@ describe(SearchService.name, () => { const result = await sut.handleSearchDuplicates({ id: assetStub.hasEmbedding.id }); expect(result).toBe(JobStatus.SUCCESS); - expect(searchMock.searchDuplicates).toHaveBeenCalledWith({ + expect(mocks.search.searchDuplicates).toHaveBeenCalledWith({ assetId: assetStub.hasEmbedding.id, embedding: assetStub.hasEmbedding.smartSearch!.embedding, maxDistance: 0.01, type: assetStub.hasEmbedding.type, userIds: [assetStub.hasEmbedding.ownerId], }); - expect(assetMock.updateDuplicates).toHaveBeenCalledWith({ + expect(mocks.asset.updateDuplicates).toHaveBeenCalledWith({ assetIds: expectedAssetIds, targetDuplicateId: expect.any(String), duplicateIds: [], }); - expect(assetMock.upsertJobStatus).toHaveBeenCalledWith( + expect(mocks.asset.upsertJobStatus).toHaveBeenCalledWith( ...expectedAssetIds.map((assetId) => ({ assetId, duplicatesDetectedAt: expect.any(Date) })), ); }); it('should use existing duplicate ID among matched duplicates', async () => { const duplicateId = assetStub.hasDupe.duplicateId; - assetMock.getById.mockResolvedValue(assetStub.hasEmbedding); - searchMock.searchDuplicates.mockResolvedValue([{ assetId: assetStub.hasDupe.id, distance: 0.01, duplicateId }]); + mocks.asset.getById.mockResolvedValue(assetStub.hasEmbedding); + mocks.search.searchDuplicates.mockResolvedValue([{ assetId: assetStub.hasDupe.id, distance: 0.01, duplicateId }]); const expectedAssetIds = [assetStub.hasEmbedding.id]; const result = await sut.handleSearchDuplicates({ id: assetStub.hasEmbedding.id }); expect(result).toBe(JobStatus.SUCCESS); - expect(searchMock.searchDuplicates).toHaveBeenCalledWith({ + expect(mocks.search.searchDuplicates).toHaveBeenCalledWith({ assetId: assetStub.hasEmbedding.id, embedding: assetStub.hasEmbedding.smartSearch!.embedding, maxDistance: 0.01, type: assetStub.hasEmbedding.type, userIds: [assetStub.hasEmbedding.ownerId], }); - expect(assetMock.updateDuplicates).toHaveBeenCalledWith({ + expect(mocks.asset.updateDuplicates).toHaveBeenCalledWith({ assetIds: expectedAssetIds, targetDuplicateId: assetStub.hasDupe.duplicateId, duplicateIds: [], }); - expect(assetMock.upsertJobStatus).toHaveBeenCalledWith( + expect(mocks.asset.upsertJobStatus).toHaveBeenCalledWith( ...expectedAssetIds.map((assetId) => ({ assetId, duplicatesDetectedAt: expect.any(Date) })), ); }); it('should remove duplicateId if no duplicates found and asset has duplicateId', async () => { - assetMock.getById.mockResolvedValue(assetStub.hasDupe); - searchMock.searchDuplicates.mockResolvedValue([]); + mocks.asset.getById.mockResolvedValue(assetStub.hasDupe); + mocks.search.searchDuplicates.mockResolvedValue([]); const result = await sut.handleSearchDuplicates({ id: assetStub.hasDupe.id }); expect(result).toBe(JobStatus.SUCCESS); - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.hasDupe.id, duplicateId: null }); - expect(assetMock.upsertJobStatus).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.hasDupe.id, duplicateId: null }); + expect(mocks.asset.upsertJobStatus).toHaveBeenCalledWith({ assetId: assetStub.hasDupe.id, duplicatesDetectedAt: expect.any(Date), }); diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index 7e8ea49991..74b86f8e4e 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -1,13 +1,15 @@ import { Injectable } from '@nestjs/common'; +import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { OnJob } from 'src/decorators'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { DuplicateResponseDto } from 'src/dtos/duplicate.dto'; import { AssetEntity } from 'src/entities/asset.entity'; -import { WithoutProperty } from 'src/interfaces/asset.interface'; -import { JOBS_ASSET_PAGINATION_SIZE, JobName, JobOf, JobStatus, QueueName } from 'src/interfaces/job.interface'; -import { AssetDuplicateResult } from 'src/interfaces/search.interface'; +import { JobName, JobStatus, QueueName } from 'src/enum'; +import { WithoutProperty } from 'src/repositories/asset.repository'; +import { AssetDuplicateResult } from 'src/repositories/search.repository'; import { BaseService } from 'src/services/base.service'; +import { JobOf } from 'src/types'; import { getAssetFiles } from 'src/utils/asset.util'; import { isDuplicateDetectionEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; @@ -57,6 +59,11 @@ export class DuplicateService extends BaseService { return JobStatus.FAILED; } + if (asset.stackId) { + this.logger.debug(`Asset ${id} is part of a stack, skipping`); + return JobStatus.SKIPPED; + } + if (!asset.isVisible) { this.logger.debug(`Asset ${id} is not visible, skipping`); return JobStatus.SKIPPED; diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 0dd8bdae66..b214dd14f6 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -1,6 +1,6 @@ import { ActivityService } from 'src/services/activity.service'; import { AlbumService } from 'src/services/album.service'; -import { APIKeyService } from 'src/services/api-key.service'; +import { ApiKeyService } from 'src/services/api-key.service'; import { ApiService } from 'src/services/api.service'; import { AssetMediaService } from 'src/services/asset-media.service'; import { AssetService } from 'src/services/asset.service'; @@ -40,7 +40,7 @@ import { VersionService } from 'src/services/version.service'; import { ViewService } from 'src/services/view.service'; export const services = [ - APIKeyService, + ApiKeyService, ActivityService, AlbumService, ApiService, diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index 5d11f895a1..134a86b69f 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -1,27 +1,19 @@ import { BadRequestException } from '@nestjs/common'; import { defaults, SystemConfig } from 'src/config'; -import { ImmichWorker } from 'src/enum'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IJobRepository, JobCommand, JobItem, JobName, JobStatus, QueueName } from 'src/interfaces/job.interface'; +import { ImmichWorker, JobCommand, JobName, JobStatus, QueueName } from 'src/enum'; import { JobService } from 'src/services/job.service'; -import { IConfigRepository, ILoggingRepository } from 'src/types'; +import { JobItem } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; -import { ITelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(JobService.name, () => { let sut: JobService; - let assetMock: Mocked; - let configMock: Mocked; - let jobMock: Mocked; - let loggerMock: Mocked; - let telemetryMock: ITelemetryRepositoryMock; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, assetMock, configMock, jobMock, loggerMock, telemetryMock } = newTestService(JobService, {})); + ({ sut, mocks } = newTestService(JobService, {})); - configMock.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); + mocks.config.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); }); it('should work', () => { @@ -32,11 +24,11 @@ describe(JobService.name, () => { it('should update concurrency', () => { sut.onConfigUpdate({ newConfig: defaults, oldConfig: {} as SystemConfig }); - expect(jobMock.setConcurrency).toHaveBeenCalledTimes(15); - expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FACIAL_RECOGNITION, 1); - expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DUPLICATE_DETECTION, 1); - expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BACKGROUND_TASK, 5); - expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(9, QueueName.STORAGE_TEMPLATE_MIGRATION, 1); + expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(15); + expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FACIAL_RECOGNITION, 1); + expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DUPLICATE_DETECTION, 1); + expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BACKGROUND_TASK, 5); + expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(9, QueueName.STORAGE_TEMPLATE_MIGRATION, 1); }); }); @@ -44,10 +36,12 @@ describe(JobService.name, () => { it('should run the scheduled jobs', async () => { await sut.handleNightlyJobs(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.ASSET_DELETION_CHECK }, { name: JobName.USER_DELETE_CHECK }, { name: JobName.PERSON_CLEANUP }, + { name: JobName.MEMORIES_CLEANUP }, + { name: JobName.MEMORIES_CREATE }, { name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }, { name: JobName.CLEAN_OLD_AUDIT_LOGS }, { name: JobName.USER_SYNC_USAGE }, @@ -59,7 +53,7 @@ describe(JobService.name, () => { describe('getAllJobStatus', () => { it('should get all job statuses', async () => { - jobMock.getJobCounts.mockResolvedValue({ + mocks.job.getJobCounts.mockResolvedValue({ active: 1, completed: 1, failed: 1, @@ -67,7 +61,7 @@ describe(JobService.name, () => { waiting: 1, paused: 1, }); - jobMock.getQueueStatus.mockResolvedValue({ + mocks.job.getQueueStatus.mockResolvedValue({ isActive: true, isPaused: true, }); @@ -111,121 +105,129 @@ describe(JobService.name, () => { it('should handle a pause command', async () => { await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.PAUSE, force: false }); - expect(jobMock.pause).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); + expect(mocks.job.pause).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); }); it('should handle a resume command', async () => { await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.RESUME, force: false }); - expect(jobMock.resume).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); + expect(mocks.job.resume).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); }); it('should handle an empty command', async () => { await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.EMPTY, force: false }); - expect(jobMock.empty).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); + expect(mocks.job.empty).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); }); it('should not start a job that is already running', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: true, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: true, isPaused: false }); await expect( sut.handleCommand(QueueName.VIDEO_CONVERSION, { command: JobCommand.START, force: false }), ).rejects.toBeInstanceOf(BadRequestException); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); }); it('should handle a start video conversion command', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.handleCommand(QueueName.VIDEO_CONVERSION, { command: JobCommand.START, force: false }); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_VIDEO_CONVERSION, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_VIDEO_CONVERSION, data: { force: false } }); }); it('should handle a start storage template migration command', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.handleCommand(QueueName.STORAGE_TEMPLATE_MIGRATION, { command: JobCommand.START, force: false }); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.STORAGE_TEMPLATE_MIGRATION }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.STORAGE_TEMPLATE_MIGRATION }); }); it('should handle a start smart search command', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.handleCommand(QueueName.SMART_SEARCH, { command: JobCommand.START, force: false }); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_SMART_SEARCH, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_SMART_SEARCH, data: { force: false } }); }); it('should handle a start metadata extraction command', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.START, force: false }); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force: false } }); }); it('should handle a start sidecar command', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.handleCommand(QueueName.SIDECAR, { command: JobCommand.START, force: false }); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_SIDECAR, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_SIDECAR, data: { force: false } }); }); it('should handle a start thumbnail generation command', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.handleCommand(QueueName.THUMBNAIL_GENERATION, { command: JobCommand.START, force: false }); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }); }); it('should handle a start face detection command', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.handleCommand(QueueName.FACE_DETECTION, { command: JobCommand.START, force: false }); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_FACE_DETECTION, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_FACE_DETECTION, data: { force: false } }); }); it('should handle a start facial recognition command', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.handleCommand(QueueName.FACIAL_RECOGNITION, { command: JobCommand.START, force: false }); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }); + }); + + it('should handle a start backup database command', async () => { + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + + await sut.handleCommand(QueueName.BACKUP_DATABASE, { command: JobCommand.START, force: false }); + + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.BACKUP_DATABASE, data: { force: false } }); }); it('should throw a bad request when an invalid queue is used', async () => { - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await expect( sut.handleCommand(QueueName.BACKGROUND_TASK, { command: JobCommand.START, force: false }), ).rejects.toBeInstanceOf(BadRequestException); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); }); }); describe('onJobStart', () => { it('should process a successful job', async () => { - jobMock.run.mockResolvedValue(JobStatus.SUCCESS); + mocks.job.run.mockResolvedValue(JobStatus.SUCCESS); await sut.onJobStart(QueueName.BACKGROUND_TASK, { name: JobName.DELETE_FILES, data: { files: ['path/to/file'] }, }); - expect(telemetryMock.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', 1); - expect(telemetryMock.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', -1); - expect(telemetryMock.jobs.addToCounter).toHaveBeenCalledWith('immich.jobs.delete_files.success', 1); - expect(loggerMock.error).not.toHaveBeenCalled(); + expect(mocks.telemetry.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', 1); + expect(mocks.telemetry.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', -1); + expect(mocks.telemetry.jobs.addToCounter).toHaveBeenCalledWith('immich.jobs.delete_files.success', 1); + expect(mocks.logger.error).not.toHaveBeenCalled(); }); const tests: Array<{ item: JobItem; jobs: JobName[] }> = [ @@ -239,10 +241,6 @@ describe(JobService.name, () => { }, { item: { name: JobName.METADATA_EXTRACTION, data: { id: 'asset-1' } }, - jobs: [JobName.LINK_LIVE_PHOTOS], - }, - { - item: { name: JobName.LINK_LIVE_PHOTOS, data: { id: 'asset-1' } }, jobs: [JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE], }, { @@ -287,34 +285,34 @@ describe(JobService.name, () => { it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => { if (item.name === JobName.GENERATE_THUMBNAILS && item.data.source === 'upload') { if (item.data.id === 'asset-live-image') { - assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoStillAsset]); + mocks.asset.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoStillAsset]); } else { - assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoMotionAsset]); + mocks.asset.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoMotionAsset]); } } - jobMock.run.mockResolvedValue(JobStatus.SUCCESS); + mocks.job.run.mockResolvedValue(JobStatus.SUCCESS); await sut.onJobStart(QueueName.BACKGROUND_TASK, item); if (jobs.length > 1) { - expect(jobMock.queueAll).toHaveBeenCalledWith( + expect(mocks.job.queueAll).toHaveBeenCalledWith( jobs.map((jobName) => ({ name: jobName, data: expect.anything() })), ); } else { - expect(jobMock.queue).toHaveBeenCalledTimes(jobs.length); + expect(mocks.job.queue).toHaveBeenCalledTimes(jobs.length); for (const jobName of jobs) { - expect(jobMock.queue).toHaveBeenCalledWith({ name: jobName, data: expect.anything() }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: jobName, data: expect.anything() }); } } }); it(`should not queue any jobs when ${item.name} fails`, async () => { - jobMock.run.mockResolvedValue(JobStatus.FAILED); + mocks.job.run.mockResolvedValue(JobStatus.FAILED); await sut.onJobStart(QueueName.BACKGROUND_TASK, item); - expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); }); } }); diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index c8ac8fc6bf..2f180edd40 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -3,18 +3,19 @@ import { snakeCase } from 'lodash'; import { OnEvent } from 'src/decorators'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto'; -import { AssetType, ImmichWorker, ManualJobName } from 'src/enum'; -import { ArgOf, ArgsOf } from 'src/interfaces/event.interface'; import { - ConcurrentQueueName, + AssetType, + ImmichWorker, JobCommand, - JobItem, JobName, JobStatus, + ManualJobName, QueueCleanType, QueueName, -} from 'src/interfaces/job.interface'; +} from 'src/enum'; +import { ArgOf, ArgsOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; +import { ConcurrentQueueName, JobItem } from 'src/types'; const asJobItem = (dto: JobCreateDto): JobItem => { switch (dto.name) { @@ -30,6 +31,18 @@ const asJobItem = (dto: JobCreateDto): JobItem => { return { name: JobName.USER_DELETE_CHECK }; } + case ManualJobName.MEMORY_CLEANUP: { + return { name: JobName.MEMORIES_CLEANUP }; + } + + case ManualJobName.MEMORY_CREATE: { + return { name: JobName.MEMORIES_CREATE }; + } + + case ManualJobName.BACKUP_DATABASE: { + return { name: JobName.BACKUP_DATABASE }; + } + default: { throw new BadRequestException('Invalid job name'); } @@ -61,7 +74,7 @@ export class JobService extends BaseService { } async handleCommand(queueName: QueueName, dto: JobCommandDto): Promise { - this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`); + this.logger.debug(`Handling command: queue=${queueName},command=${dto.command},force=${dto.force}`); switch (dto.command) { case JobCommand.START: { @@ -161,7 +174,7 @@ export class JobService extends BaseService { } case QueueName.LIBRARY: { - return this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ALL, data: { force } }); + return this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: { force } }); } case QueueName.BACKUP_DATABASE: { @@ -186,7 +199,11 @@ export class JobService extends BaseService { await this.onDone(job); } } catch (error: Error | any) { - this.logger.error(`Unable to run job handler (${queueName}/${job.name}): ${error}`, error?.stack, job.data); + this.logger.error( + `Unable to run job handler (${queueName}/${job.name}): ${error}`, + error?.stack, + JSON.stringify(job.data), + ); } finally { this.telemetryRepository.jobs.addToGauge(queueMetric, -1); } @@ -206,6 +223,8 @@ export class JobService extends BaseService { { name: JobName.ASSET_DELETION_CHECK }, { name: JobName.USER_DELETE_CHECK }, { name: JobName.PERSON_CLEANUP }, + { name: JobName.MEMORIES_CLEANUP }, + { name: JobName.MEMORIES_CREATE }, { name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }, { name: JobName.CLEAN_OLD_AUDIT_LOGS }, { name: JobName.USER_SYNC_USAGE }, @@ -240,11 +259,6 @@ export class JobService extends BaseService { this.eventRepository.clientSend('on_asset_update', asset.ownerId, mapAsset(asset)); } } - await this.jobRepository.queue({ name: JobName.LINK_LIVE_PHOTOS, data: item.data }); - break; - } - - case JobName.LINK_LIVE_PHOTOS: { await this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: item.data }); break; } diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 5f81d92ec2..aef02b7244 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -1,31 +1,18 @@ import { BadRequestException } from '@nestjs/common'; import { Stats } from 'node:fs'; import { defaults, SystemConfig } from 'src/config'; +import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants'; import { mapLibrary } from 'src/dtos/library.dto'; -import { UserEntity } from 'src/entities/user.entity'; -import { AssetType, ImmichWorker } from 'src/enum'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IDatabaseRepository } from 'src/interfaces/database.interface'; -import { - IJobRepository, - ILibraryAssetJob, - ILibraryFileJob, - JobName, - JOBS_LIBRARY_PAGINATION_SIZE, - JobStatus, -} from 'src/interfaces/job.interface'; -import { ILibraryRepository } from 'src/interfaces/library.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; +import { AssetType, ImmichWorker, JobName, JobStatus } from 'src/enum'; import { LibraryService } from 'src/services/library.service'; -import { IConfigRepository, ICronRepository } from 'src/types'; +import { ILibraryBulkIdsJob, ILibraryFileJob } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { libraryStub } from 'test/fixtures/library.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { userStub } from 'test/fixtures/user.stub'; import { makeMockWatcher } from 'test/repositories/storage.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked, vitest } from 'vitest'; +import { factory, newUuid } from 'test/small.factory'; +import { makeStream, newTestService, ServiceMocks } from 'test/utils'; +import { vitest } from 'vitest'; async function* mockWalk() { yield await Promise.resolve(['/data/user1/photo.jpg']); @@ -34,20 +21,13 @@ async function* mockWalk() { describe(LibraryService.name, () => { let sut: LibraryService; - let assetMock: Mocked; - let configMock: Mocked; - let cronMock: Mocked; - let databaseMock: Mocked; - let jobMock: Mocked; - let libraryMock: Mocked; - let storageMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, assetMock, configMock, cronMock, databaseMock, jobMock, libraryMock, storageMock } = - newTestService(LibraryService)); + ({ sut, mocks } = newTestService(LibraryService, {})); - databaseMock.tryLock.mockResolvedValue(true); - configMock.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); + mocks.database.tryLock.mockResolvedValue(true); + mocks.config.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); }); it('should work', () => { @@ -56,9 +36,12 @@ describe(LibraryService.name, () => { describe('onConfigInit', () => { it('should init cron job and handle config changes', async () => { + mocks.cron.create.mockResolvedValue(); + mocks.cron.update.mockResolvedValue(); + await sut.onConfigInit({ newConfig: defaults }); - expect(cronMock.create).toHaveBeenCalled(); + expect(mocks.cron.create).toHaveBeenCalled(); await sut.onConfigUpdate({ oldConfig: defaults, @@ -73,77 +56,78 @@ describe(LibraryService.name, () => { } as SystemConfig, }); - expect(cronMock.update).toHaveBeenCalledWith({ name: 'libraryScan', expression: '0 1 * * *', start: true }); + expect(mocks.cron.update).toHaveBeenCalledWith({ name: 'libraryScan', expression: '0 1 * * *', start: true }); }); it('should initialize watcher for all external libraries', async () => { - libraryMock.getAll.mockResolvedValue([ - libraryStub.externalLibraryWithImportPaths1, - libraryStub.externalLibraryWithImportPaths2, - ]); + const library1 = factory.library({ importPaths: ['/foo', '/bar'] }); + const library2 = factory.library({ importPaths: ['/xyz', '/asdf'] }); - libraryMock.get.mockImplementation((id) => - Promise.resolve( - [libraryStub.externalLibraryWithImportPaths1, libraryStub.externalLibraryWithImportPaths2].find( - (library) => library.id === id, - ), - ), + mocks.library.getAll.mockResolvedValue([library1, library2]); + + mocks.library.get.mockImplementation((id) => + Promise.resolve([library1, library2].find((library) => library.id === id)), ); + mocks.cron.create.mockResolvedValue(); await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchEnabled as SystemConfig }); - expect(storageMock.watch.mock.calls).toEqual( - expect.arrayContaining([ - (libraryStub.externalLibrary1.importPaths, expect.anything()), - (libraryStub.externalLibrary2.importPaths, expect.anything()), - ]), + expect(mocks.storage.watch.mock.calls).toEqual( + expect.arrayContaining([(library1.importPaths, expect.anything()), (library2.importPaths, expect.anything())]), ); }); it('should not initialize watcher when watching is disabled', async () => { + mocks.cron.create.mockResolvedValue(); + await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchDisabled as SystemConfig }); - expect(storageMock.watch).not.toHaveBeenCalled(); + expect(mocks.storage.watch).not.toHaveBeenCalled(); }); it('should not initialize watcher when lock is taken', async () => { - databaseMock.tryLock.mockResolvedValue(false); + mocks.database.tryLock.mockResolvedValue(false); await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchEnabled as SystemConfig }); - expect(storageMock.watch).not.toHaveBeenCalled(); + expect(mocks.storage.watch).not.toHaveBeenCalled(); }); it('should not initialize library scan cron job when lock is taken', async () => { - databaseMock.tryLock.mockResolvedValue(false); + mocks.database.tryLock.mockResolvedValue(false); await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchEnabled as SystemConfig }); - expect(cronMock.create).not.toHaveBeenCalled(); + expect(mocks.cron.create).not.toHaveBeenCalled(); }); }); describe('onConfigUpdateEvent', () => { beforeEach(async () => { - databaseMock.tryLock.mockResolvedValue(true); + mocks.database.tryLock.mockResolvedValue(true); + mocks.cron.create.mockResolvedValue(); + await sut.onConfigInit({ newConfig: defaults }); }); it('should do nothing if instance does not have the watch lock', async () => { - databaseMock.tryLock.mockResolvedValue(false); + mocks.database.tryLock.mockResolvedValue(false); await sut.onConfigInit({ newConfig: defaults }); await sut.onConfigUpdate({ newConfig: systemConfigStub.libraryScan as SystemConfig, oldConfig: defaults }); - expect(cronMock.update).not.toHaveBeenCalled(); + expect(mocks.cron.update).not.toHaveBeenCalled(); }); it('should update cron job and enable watching', async () => { - libraryMock.getAll.mockResolvedValue([]); + mocks.library.getAll.mockResolvedValue([]); + mocks.cron.create.mockResolvedValue(); + mocks.cron.update.mockResolvedValue(); + await sut.onConfigUpdate({ newConfig: systemConfigStub.libraryScanAndWatch as SystemConfig, oldConfig: defaults, }); - expect(cronMock.update).toHaveBeenCalledWith({ + expect(mocks.cron.update).toHaveBeenCalledWith({ name: 'libraryScan', expression: systemConfigStub.libraryScan.library.scan.cronExpression, start: systemConfigStub.libraryScan.library.scan.enabled, @@ -151,7 +135,10 @@ describe(LibraryService.name, () => { }); it('should update cron job and disable watching', async () => { - libraryMock.getAll.mockResolvedValue([]); + mocks.library.getAll.mockResolvedValue([]); + mocks.cron.create.mockResolvedValue(); + mocks.cron.update.mockResolvedValue(); + await sut.onConfigUpdate({ newConfig: systemConfigStub.libraryScanAndWatch as SystemConfig, oldConfig: defaults, @@ -161,7 +148,7 @@ describe(LibraryService.name, () => { oldConfig: defaults, }); - expect(cronMock.update).toHaveBeenCalledWith({ + expect(mocks.cron.update).toHaveBeenCalledWith({ name: 'libraryScan', expression: systemConfigStub.libraryScan.library.scan.cronExpression, start: systemConfigStub.libraryScan.library.scan.enabled, @@ -171,30 +158,36 @@ describe(LibraryService.name, () => { describe('handleQueueSyncFiles', () => { it('should queue refresh of a new asset', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - storageMock.walk.mockImplementation(mockWalk); + const library = factory.library({ importPaths: ['/foo', '/bar'] }); - await sut.handleQueueSyncFiles({ id: libraryStub.externalLibrary1.id }); + mocks.library.get.mockResolvedValue(library); + mocks.storage.walk.mockImplementation(mockWalk); + mocks.storage.stat.mockResolvedValue({ isDirectory: () => true } as Stats); + mocks.storage.checkFileExists.mockResolvedValue(true); + mocks.asset.filterNewExternalAssetPaths.mockResolvedValue(['/data/user1/photo.jpg']); - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { - name: JobName.LIBRARY_SYNC_FILE, - data: { - id: libraryStub.externalLibrary1.id, - ownerId: libraryStub.externalLibrary1.owner.id, - assetPath: '/data/user1/photo.jpg', - }, + await sut.handleQueueSyncFiles({ id: library.id }); + + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.LIBRARY_SYNC_FILES, + data: { + libraryId: library.id, + paths: ['/data/user1/photo.jpg'], + progressCounter: 1, }, - ]); + }); }); - it("should fail when library can't be found", async () => { - await expect(sut.handleQueueSyncFiles({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SKIPPED); + it('should fail when library is not found', async () => { + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + + await expect(sut.handleQueueSyncFiles({ id: library.id })).resolves.toBe(JobStatus.SKIPPED); }); it('should ignore import paths that do not exist', async () => { - storageMock.stat.mockImplementation((path): Promise => { - if (path === libraryStub.externalLibraryWithImportPaths1.importPaths[0]) { + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + mocks.storage.stat.mockImplementation((path): Promise => { + if (path === library.importPaths[0]) { const error = { code: 'ENOENT' } as any; throw error; } @@ -203,14 +196,14 @@ describe(LibraryService.name, () => { } as Stats); }); - storageMock.checkFileExists.mockResolvedValue(true); + mocks.storage.checkFileExists.mockResolvedValue(true); - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + mocks.library.get.mockResolvedValue(library); - await sut.handleQueueSyncFiles({ id: libraryStub.externalLibraryWithImportPaths1.id }); + await sut.handleQueueSyncFiles({ id: library.id }); - expect(storageMock.walk).toHaveBeenCalledWith({ - pathsToCrawl: [libraryStub.externalLibraryWithImportPaths1.importPaths[1]], + expect(mocks.storage.walk).toHaveBeenCalledWith({ + pathsToCrawl: [library.importPaths[1]], exclusionPatterns: [], includeHidden: false, take: JOBS_LIBRARY_PAGINATION_SIZE, @@ -218,162 +211,333 @@ describe(LibraryService.name, () => { }); }); - describe('handleQueueRemoveDeleted', () => { - it('should queue online check of existing assets', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - storageMock.walk.mockImplementation(async function* generator() {}); - assetMock.getAll.mockResolvedValue({ items: [assetStub.external], hasNextPage: false }); + describe('handleQueueSyncFiles', () => { + it('should queue refresh of a new asset', async () => { + const library = factory.library({ importPaths: ['/foo', '/bar'] }); - await sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id }); + mocks.library.get.mockResolvedValue(library); + mocks.storage.walk.mockImplementation(mockWalk); + mocks.storage.stat.mockResolvedValue({ isDirectory: () => true } as Stats); + mocks.storage.checkFileExists.mockResolvedValue(true); + mocks.asset.filterNewExternalAssetPaths.mockResolvedValue(['/data/user1/photo.jpg']); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + await sut.handleQueueSyncFiles({ id: library.id }); + + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.LIBRARY_SYNC_FILES, + data: { + libraryId: library.id, + paths: ['/data/user1/photo.jpg'], + progressCounter: 1, + }, + }); + }); + + it("should fail when library can't be found", async () => { + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + + await expect(sut.handleQueueSyncFiles({ id: library.id })).resolves.toBe(JobStatus.SKIPPED); + }); + + it('should ignore import paths that do not exist', async () => { + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + + mocks.storage.stat.mockImplementation((path): Promise => { + if (path === library.importPaths[0]) { + const error = { code: 'ENOENT' } as any; + throw error; + } + return Promise.resolve({ + isDirectory: () => true, + } as Stats); + }); + + mocks.storage.checkFileExists.mockResolvedValue(true); + + mocks.library.get.mockResolvedValue(library); + + await sut.handleQueueSyncFiles({ id: library.id }); + + expect(mocks.storage.walk).toHaveBeenCalledWith({ + pathsToCrawl: [library.importPaths[1]], + exclusionPatterns: [], + includeHidden: false, + take: JOBS_LIBRARY_PAGINATION_SIZE, + }); + }); + }); + + describe('handleQueueSyncAssets', () => { + it('should call the offline check', async () => { + const library = factory.library(); + + mocks.library.get.mockResolvedValue(library); + mocks.storage.walk.mockImplementation(async function* generator() {}); + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.external], hasNextPage: false }); + mocks.asset.getLibraryAssetCount.mockResolvedValue(1); + mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: BigInt(1) }); + + const response = await sut.handleQueueSyncAssets({ id: library.id }); + + expect(response).toBe(JobStatus.SUCCESS); + expect(mocks.asset.detectOfflineExternalAssets).toHaveBeenCalledWith( + library.id, + library.importPaths, + library.exclusionPatterns, + ); + }); + + it('should skip an empty library', async () => { + const library = factory.library(); + + mocks.library.get.mockResolvedValue(library); + mocks.storage.walk.mockImplementation(async function* generator() {}); + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.external], hasNextPage: false }); + mocks.asset.getLibraryAssetCount.mockResolvedValue(0); + mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: BigInt(1) }); + + const response = await sut.handleQueueSyncAssets({ id: library.id }); + + expect(response).toBe(JobStatus.SUCCESS); + expect(mocks.asset.detectOfflineExternalAssets).not.toHaveBeenCalled(); + }); + + it('should queue asset sync', async () => { + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + + mocks.library.get.mockResolvedValue(library); + mocks.storage.walk.mockImplementation(async function* generator() {}); + mocks.library.streamAssetIds.mockReturnValue(makeStream([assetStub.external])); + mocks.asset.getLibraryAssetCount.mockResolvedValue(1); + mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: BigInt(0) }); + mocks.library.streamAssetIds.mockReturnValue(makeStream([assetStub.external])); + + const response = await sut.handleQueueSyncAssets({ id: library.id }); + + expect(mocks.job.queue).toBeCalledWith({ + name: JobName.LIBRARY_SYNC_ASSETS, + data: { + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [assetStub.external.id], + progressCounter: 1, + totalAssets: 1, + }, + }); + + expect(response).toBe(JobStatus.SUCCESS); + expect(mocks.asset.detectOfflineExternalAssets).toHaveBeenCalledWith( + library.id, + library.importPaths, + library.exclusionPatterns, + ); + }); + + it("should fail if library can't be found", async () => { + await expect(sut.handleQueueSyncAssets({ id: newUuid() })).resolves.toBe(JobStatus.SKIPPED); + }); + }); + + describe('handleSyncAssets', () => { + it('should offline assets no longer on disk', async () => { + const mockAssetJob: ILibraryBulkIdsJob = { + assetIds: [assetStub.external.id], + libraryId: newUuid(), + importPaths: ['/'], + exclusionPatterns: [], + totalAssets: 1, + progressCounter: 0, + }; + + mocks.asset.getByIds.mockResolvedValue([assetStub.external]); + mocks.storage.stat.mockRejectedValue(new Error('ENOENT, no such file or directory')); + + await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + + expect(mocks.asset.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + isOffline: true, + deletedAt: expect.anything(), + }); + }); + + it('should set assets deleted from disk as offline', async () => { + const mockAssetJob: ILibraryBulkIdsJob = { + assetIds: [assetStub.external.id], + libraryId: newUuid(), + importPaths: ['/data/user2'], + exclusionPatterns: [], + totalAssets: 1, + progressCounter: 0, + }; + + mocks.asset.getByIds.mockResolvedValue([assetStub.external]); + mocks.storage.stat.mockRejectedValue(new Error('Could not read file')); + + await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + + expect(mocks.asset.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + isOffline: true, + deletedAt: expect.anything(), + }); + }); + + it('should do nothing with offline assets deleted from disk', async () => { + const mockAssetJob: ILibraryBulkIdsJob = { + assetIds: [assetStub.trashedOffline.id], + libraryId: newUuid(), + importPaths: ['/data/user2'], + exclusionPatterns: [], + totalAssets: 1, + progressCounter: 0, + }; + + mocks.asset.getByIds.mockResolvedValue([assetStub.trashedOffline]); + mocks.storage.stat.mockRejectedValue(new Error('Could not read file')); + + await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + + expect(mocks.asset.updateAll).not.toHaveBeenCalled(); + }); + + it('should un-trash an asset previously marked as offline', async () => { + const mockAssetJob: ILibraryBulkIdsJob = { + assetIds: [assetStub.trashedOffline.id], + libraryId: newUuid(), + importPaths: ['/original/'], + exclusionPatterns: [], + totalAssets: 1, + progressCounter: 0, + }; + + mocks.asset.getByIds.mockResolvedValue([assetStub.trashedOffline]); + mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); + + await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + + expect(mocks.asset.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + isOffline: false, + deletedAt: null, + }); + }); + + it('should do nothing with offline asset if covered by exclusion pattern', async () => { + const mockAssetJob: ILibraryBulkIdsJob = { + assetIds: [assetStub.trashedOffline.id], + libraryId: newUuid(), + importPaths: ['/original/'], + exclusionPatterns: ['**/path.jpg'], + totalAssets: 1, + progressCounter: 0, + }; + + mocks.asset.getByIds.mockResolvedValue([assetStub.trashedOffline]); + mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); + + await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + + expect(mocks.asset.updateAll).not.toHaveBeenCalled(); + + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + }); + + it('should do nothing with offline asset if not in import path', async () => { + const mockAssetJob: ILibraryBulkIdsJob = { + assetIds: [assetStub.trashedOffline.id], + libraryId: newUuid(), + importPaths: ['/import/'], + exclusionPatterns: [], + totalAssets: 1, + progressCounter: 0, + }; + + mocks.asset.getByIds.mockResolvedValue([assetStub.trashedOffline]); + mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); + + await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + + expect(mocks.asset.updateAll).not.toHaveBeenCalled(); + + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + }); + + it('should do nothing with unchanged online assets', async () => { + const mockAssetJob: ILibraryBulkIdsJob = { + assetIds: [assetStub.external.id], + libraryId: newUuid(), + importPaths: ['/'], + exclusionPatterns: [], + totalAssets: 1, + progressCounter: 0, + }; + + mocks.asset.getByIds.mockResolvedValue([assetStub.external]); + mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); + + await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + + expect(mocks.asset.updateAll).not.toHaveBeenCalled(); + }); + + it('should not touch fileCreatedAt when un-trashing an asset previously marked as offline', async () => { + const mockAssetJob: ILibraryBulkIdsJob = { + assetIds: [assetStub.trashedOffline.id], + libraryId: newUuid(), + importPaths: ['/'], + exclusionPatterns: [], + totalAssets: 1, + progressCounter: 0, + }; + + mocks.asset.getByIds.mockResolvedValue([assetStub.trashedOffline]); + mocks.storage.stat.mockResolvedValue({ mtime: assetStub.trashedOffline.fileModifiedAt } as Stats); + + await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + + expect(mocks.asset.updateAll).toHaveBeenCalledWith( + [assetStub.trashedOffline.id], + expect.not.objectContaining({ + fileCreatedAt: expect.anything(), + }), + ); + }); + + it('should update with online assets that have changed', async () => { + const mockAssetJob: ILibraryBulkIdsJob = { + assetIds: [assetStub.external.id], + libraryId: newUuid(), + importPaths: ['/'], + exclusionPatterns: [], + totalAssets: 1, + progressCounter: 0, + }; + + if (assetStub.external.fileModifiedAt == null) { + throw new Error('fileModifiedAt is null'); + } + + const mtime = new Date(assetStub.external.fileModifiedAt.getDate() + 1); + + mocks.asset.getByIds.mockResolvedValue([assetStub.external]); + mocks.storage.stat.mockResolvedValue({ mtime } as Stats); + + await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.LIBRARY_SYNC_ASSET, + name: JobName.SIDECAR_DISCOVERY, data: { id: assetStub.external.id, - importPaths: libraryStub.externalLibrary1.importPaths, - exclusionPatterns: [], + source: 'upload', }, }, ]); }); - - it("should fail when library can't be found", async () => { - await expect(sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SKIPPED); - }); }); - describe('handleSyncAsset', () => { - it('should skip missing assets', async () => { - const mockAssetJob: ILibraryAssetJob = { - id: assetStub.external.id, - importPaths: ['/'], - exclusionPatterns: [], - }; - - await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SKIPPED); - - expect(assetMock.remove).not.toHaveBeenCalled(); - }); - - it('should offline assets no longer on disk', async () => { - const mockAssetJob: ILibraryAssetJob = { - id: assetStub.external.id, - importPaths: ['/'], - exclusionPatterns: [], - }; - - assetMock.getById.mockResolvedValue(assetStub.external); - storageMock.stat.mockRejectedValue(new Error('ENOENT, no such file or directory')); - - await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { - isOffline: true, - deletedAt: expect.any(Date), - }); - }); - - it('should offline assets matching an exclusion pattern', async () => { - const mockAssetJob: ILibraryAssetJob = { - id: assetStub.external.id, - importPaths: ['/'], - exclusionPatterns: ['**/user1/**'], - }; - - assetMock.getById.mockResolvedValue(assetStub.external); - - await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { - isOffline: true, - deletedAt: expect.any(Date), - }); - }); - - it('should set assets outside of import paths as offline', async () => { - const mockAssetJob: ILibraryAssetJob = { - id: assetStub.external.id, - importPaths: ['/data/user2'], - exclusionPatterns: [], - }; - - assetMock.getById.mockResolvedValue(assetStub.external); - storageMock.checkFileExists.mockResolvedValue(true); - - await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { - isOffline: true, - deletedAt: expect.any(Date), - }); - }); - - it('should do nothing with online assets', async () => { - const mockAssetJob: ILibraryAssetJob = { - id: assetStub.external.id, - importPaths: ['/'], - exclusionPatterns: [], - }; - - assetMock.getById.mockResolvedValue(assetStub.external); - storageMock.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); - - await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.updateAll).not.toHaveBeenCalled(); - }); - - it('should un-trash an asset previously marked as offline', async () => { - const mockAssetJob: ILibraryAssetJob = { - id: assetStub.external.id, - importPaths: ['/'], - exclusionPatterns: [], - }; - - assetMock.getById.mockResolvedValue(assetStub.trashedOffline); - storageMock.stat.mockResolvedValue({ mtime: assetStub.trashedOffline.fileModifiedAt } as Stats); - - await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.trashedOffline.id], { - deletedAt: null, - fileCreatedAt: assetStub.trashedOffline.fileModifiedAt, - fileModifiedAt: assetStub.trashedOffline.fileModifiedAt, - isOffline: false, - originalFileName: 'path.jpg', - }); - }); - }); - - it('should update file when mtime has changed', async () => { - const mockAssetJob: ILibraryAssetJob = { - id: assetStub.external.id, - importPaths: ['/'], - exclusionPatterns: [], - }; - - const newMTime = new Date(); - assetMock.getById.mockResolvedValue(assetStub.external); - storageMock.stat.mockResolvedValue({ mtime: newMTime } as Stats); - - await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { - fileModifiedAt: newMTime, - fileCreatedAt: newMTime, - isOffline: false, - originalFileName: 'photo.jpg', - deletedAt: null, - }); - }); - - describe('handleSyncFile', () => { - let mockUser: UserEntity; - + describe('handleSyncFiles', () => { beforeEach(() => { - mockUser = userStub.admin; - - storageMock.stat.mockResolvedValue({ + mocks.storage.stat.mockResolvedValue({ size: 100, mtime: new Date('2023-01-01'), ctime: new Date('2023-01-01'), @@ -381,230 +545,99 @@ describe(LibraryService.name, () => { }); it('should import a new asset', async () => { + const library = factory.library(); + const mockLibraryJob: ILibraryFileJob = { - id: libraryStub.externalLibrary1.id, - ownerId: mockUser.id, - assetPath: '/data/user1/photo.jpg', + libraryId: library.id, + paths: ['/data/user1/photo.jpg'], }; - assetMock.create.mockResolvedValue(assetStub.image); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + mocks.asset.createAll.mockResolvedValue([assetStub.image]); + mocks.library.get.mockResolvedValue(library); - await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncFiles(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.create.mock.calls).toEqual([ - [ - { - ownerId: mockUser.id, - libraryId: libraryStub.externalLibrary1.id, - checksum: expect.any(Buffer), - originalPath: '/data/user1/photo.jpg', - deviceAssetId: expect.any(String), - deviceId: 'Library Import', - fileCreatedAt: expect.any(Date), - fileModifiedAt: expect.any(Date), - localDateTime: expect.any(Date), - type: AssetType.IMAGE, - originalFileName: 'photo.jpg', - isExternal: true, - }, - ], + expect(mocks.asset.createAll).toHaveBeenCalledWith([ + expect.objectContaining({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: '/data/user1/photo.jpg', + deviceId: 'Library Import', + type: AssetType.IMAGE, + originalFileName: 'photo.jpg', + isExternal: true, + }), ]); - expect(jobMock.queue.mock.calls).toEqual([ - [ - { - name: JobName.SIDECAR_DISCOVERY, - data: { - id: assetStub.image.id, - source: 'upload', - }, + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { + name: JobName.SIDECAR_DISCOVERY, + data: { + id: assetStub.image.id, + source: 'upload', }, - ], - ]); - }); - - it('should import a new video', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: libraryStub.externalLibrary1.id, - ownerId: mockUser.id, - assetPath: '/data/user1/video.mp4', - }; - - assetMock.create.mockResolvedValue(assetStub.video); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - - await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.create.mock.calls).toEqual([ - [ - { - ownerId: mockUser.id, - libraryId: libraryStub.externalLibrary1.id, - checksum: expect.any(Buffer), - originalPath: '/data/user1/video.mp4', - deviceAssetId: expect.any(String), - deviceId: 'Library Import', - fileCreatedAt: expect.any(Date), - fileModifiedAt: expect.any(Date), - localDateTime: expect.any(Date), - type: AssetType.VIDEO, - originalFileName: 'video.mp4', - isExternal: true, - }, - ], - ]); - - expect(jobMock.queue.mock.calls).toEqual([ - [ - { - name: JobName.SIDECAR_DISCOVERY, - data: { - id: assetStub.image.id, - source: 'upload', - }, - }, - ], + }, ]); }); it('should not import an asset to a soft deleted library', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: libraryStub.externalLibrary1.id, - ownerId: mockUser.id, - assetPath: '/data/user1/photo.jpg', - }; - - assetMock.create.mockResolvedValue(assetStub.image); - libraryMock.get.mockResolvedValue({ ...libraryStub.externalLibrary1, deletedAt: new Date() }); - - await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.FAILED); - - expect(assetMock.create.mock.calls).toEqual([]); - }); - - it('should not refresh a file whose mtime matches existing asset', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: libraryStub.externalLibrary1.id, - ownerId: mockUser.id, - assetPath: assetStub.hasFileExtension.originalPath, - }; - - storageMock.stat.mockResolvedValue({ - size: 100, - mtime: assetStub.hasFileExtension.fileModifiedAt, - ctime: new Date('2023-01-01'), - } as Stats); - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.hasFileExtension); - - await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); - - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - }); - - it('should skip existing asset', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: libraryStub.externalLibrary1.id, - ownerId: mockUser.id, - assetPath: '/data/user1/photo.jpg', - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - - await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); - }); - - it('should not refresh an asset trashed by user', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: libraryStub.externalLibrary1.id, - ownerId: mockUser.id, - assetPath: assetStub.hasFileExtension.originalPath, - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.trashed); - - await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); - - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - }); - - it('should fail when the file could not be read', async () => { - storageMock.stat.mockRejectedValue(new Error('Could not read file')); + const library = factory.library({ deletedAt: new Date() }); const mockLibraryJob: ILibraryFileJob = { - id: libraryStub.externalLibrary1.id, - ownerId: userStub.admin.id, - assetPath: '/data/user1/photo.jpg', + libraryId: library.id, + paths: ['/data/user1/photo.jpg'], }; - assetMock.create.mockResolvedValue(assetStub.image); + mocks.library.get.mockResolvedValue(library); - await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.FAILED); - expect(libraryMock.get).not.toHaveBeenCalled(); - expect(assetMock.create).not.toHaveBeenCalled(); - }); + await expect(sut.handleSyncFiles(mockLibraryJob)).resolves.toBe(JobStatus.FAILED); - it('should skip if the file could not be found', async () => { - const error = new Error('File not found') as any; - error.code = 'ENOENT'; - storageMock.stat.mockRejectedValue(error); - - const mockLibraryJob: ILibraryFileJob = { - id: libraryStub.externalLibrary1.id, - ownerId: userStub.admin.id, - assetPath: '/data/user1/photo.jpg', - }; - - assetMock.create.mockResolvedValue(assetStub.image); - - await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); - expect(libraryMock.get).not.toHaveBeenCalled(); - expect(assetMock.create).not.toHaveBeenCalled(); + expect(mocks.asset.createAll.mock.calls).toEqual([]); }); }); describe('delete', () => { it('should delete a library', async () => { - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + const library = factory.library(); - await sut.delete(libraryStub.externalLibrary1.id); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.library.get.mockResolvedValue(library); - expect(jobMock.queue).toHaveBeenCalledWith({ - name: JobName.LIBRARY_DELETE, - data: { id: libraryStub.externalLibrary1.id }, - }); + await sut.delete(library.id); - expect(libraryMock.softDelete).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.LIBRARY_DELETE, data: { id: library.id } }); + expect(mocks.library.softDelete).toHaveBeenCalledWith(library.id); }); it('should allow an external library to be deleted', async () => { - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + const library = factory.library(); - await sut.delete(libraryStub.externalLibrary1.id); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.library.get.mockResolvedValue(library); - expect(jobMock.queue).toHaveBeenCalledWith({ + await sut.delete(library.id); + + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.LIBRARY_DELETE, - data: { id: libraryStub.externalLibrary1.id }, + data: { id: library.id }, }); - expect(libraryMock.softDelete).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); + expect(mocks.library.softDelete).toHaveBeenCalledWith(library.id); }); it('should unwatch an external library when deleted', async () => { - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.library.get.mockResolvedValue(library); + mocks.library.getAll.mockResolvedValue([library]); const mockClose = vitest.fn(); - storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); + mocks.storage.watch.mockImplementation(makeMockWatcher({ close: mockClose })); + mocks.cron.create.mockResolvedValue(); await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchEnabled as SystemConfig }); - await sut.delete(libraryStub.externalLibraryWithImportPaths1.id); + await sut.delete(library.id); expect(mockClose).toHaveBeenCalled(); }); @@ -612,61 +645,67 @@ describe(LibraryService.name, () => { describe('get', () => { it('should return a library', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await expect(sut.get(libraryStub.externalLibrary1.id)).resolves.toEqual( + const library = factory.library(); + + mocks.library.get.mockResolvedValue(library); + + await expect(sut.get(library.id)).resolves.toEqual( expect.objectContaining({ - id: libraryStub.externalLibrary1.id, - name: libraryStub.externalLibrary1.name, - ownerId: libraryStub.externalLibrary1.ownerId, + id: library.id, + name: library.name, + ownerId: library.ownerId, }), ); - expect(libraryMock.get).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); + expect(mocks.library.get).toHaveBeenCalledWith(library.id); }); it('should throw an error when a library is not found', async () => { - await expect(sut.get(libraryStub.externalLibrary1.id)).rejects.toBeInstanceOf(BadRequestException); - expect(libraryMock.get).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); + const library = factory.library(); + + await expect(sut.get(library.id)).rejects.toBeInstanceOf(BadRequestException); + + expect(mocks.library.get).toHaveBeenCalledWith(library.id); }); }); describe('getStatistics', () => { it('should return library statistics', async () => { - libraryMock.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 }); - await expect(sut.getStatistics(libraryStub.externalLibrary1.id)).resolves.toEqual({ + const library = factory.library(); + + mocks.library.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 }); + await expect(sut.getStatistics(library.id)).resolves.toEqual({ photos: 10, videos: 0, total: 10, usage: 1337, }); - expect(libraryMock.getStatistics).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); - }); - - it('should throw an error if the library could not be found', async () => { - await expect(sut.getStatistics('foo')).rejects.toBeInstanceOf(BadRequestException); + expect(mocks.library.getStatistics).toHaveBeenCalledWith(library.id); }); }); describe('create', () => { describe('external library', () => { it('should create with default settings', async () => { - libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); + const library = factory.library(); + + mocks.library.create.mockResolvedValue(library); await expect(sut.create({ ownerId: authStub.admin.user.id })).resolves.toEqual( expect.objectContaining({ - id: libraryStub.externalLibrary1.id, - name: libraryStub.externalLibrary1.name, - ownerId: libraryStub.externalLibrary1.ownerId, + id: library.id, + name: library.name, + ownerId: library.ownerId, assetCount: 0, importPaths: [], exclusionPatterns: [], - createdAt: libraryStub.externalLibrary1.createdAt, - updatedAt: libraryStub.externalLibrary1.updatedAt, + createdAt: library.createdAt, + updatedAt: library.updatedAt, refreshedAt: null, }), ); - expect(libraryMock.create).toHaveBeenCalledWith( + expect(mocks.library.create).toHaveBeenCalledWith( expect.objectContaining({ name: expect.any(String), importPaths: [], @@ -676,22 +715,25 @@ describe(LibraryService.name, () => { }); it('should create with name', async () => { - libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); + const library = factory.library(); + + mocks.library.create.mockResolvedValue(library); + await expect(sut.create({ ownerId: authStub.admin.user.id, name: 'My Awesome Library' })).resolves.toEqual( expect.objectContaining({ - id: libraryStub.externalLibrary1.id, - name: libraryStub.externalLibrary1.name, - ownerId: libraryStub.externalLibrary1.ownerId, + id: library.id, + name: library.name, + ownerId: library.ownerId, assetCount: 0, importPaths: [], exclusionPatterns: [], - createdAt: libraryStub.externalLibrary1.createdAt, - updatedAt: libraryStub.externalLibrary1.updatedAt, + createdAt: library.createdAt, + updatedAt: library.updatedAt, refreshedAt: null, }), ); - expect(libraryMock.create).toHaveBeenCalledWith( + expect(mocks.library.create).toHaveBeenCalledWith( expect.objectContaining({ name: 'My Awesome Library', importPaths: [], @@ -701,7 +743,9 @@ describe(LibraryService.name, () => { }); it('should create with import paths', async () => { - libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); + const library = factory.library(); + + mocks.library.create.mockResolvedValue(library); await expect( sut.create({ ownerId: authStub.admin.user.id, @@ -709,19 +753,19 @@ describe(LibraryService.name, () => { }), ).resolves.toEqual( expect.objectContaining({ - id: libraryStub.externalLibrary1.id, - name: libraryStub.externalLibrary1.name, - ownerId: libraryStub.externalLibrary1.ownerId, + id: library.id, + name: library.name, + ownerId: library.ownerId, assetCount: 0, importPaths: [], exclusionPatterns: [], - createdAt: libraryStub.externalLibrary1.createdAt, - updatedAt: libraryStub.externalLibrary1.updatedAt, + createdAt: library.createdAt, + updatedAt: library.updatedAt, refreshedAt: null, }), ); - expect(libraryMock.create).toHaveBeenCalledWith( + expect(mocks.library.create).toHaveBeenCalledWith( expect.objectContaining({ name: expect.any(String), importPaths: ['/data/images', '/data/videos'], @@ -731,19 +775,21 @@ describe(LibraryService.name, () => { }); it('should create watched with import paths', async () => { - libraryMock.create.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - libraryMock.getAll.mockResolvedValue([]); + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + + mocks.library.create.mockResolvedValue(library); + mocks.library.get.mockResolvedValue(library); + mocks.library.getAll.mockResolvedValue([]); + mocks.cron.create.mockResolvedValue(); await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchEnabled as SystemConfig }); - await sut.create({ - ownerId: authStub.admin.user.id, - importPaths: libraryStub.externalLibraryWithImportPaths1.importPaths, - }); + await sut.create({ ownerId: authStub.admin.user.id, importPaths: library.importPaths }); }); it('should create with exclusion patterns', async () => { - libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); + const library = factory.library(); + + mocks.library.create.mockResolvedValue(library); await expect( sut.create({ ownerId: authStub.admin.user.id, @@ -751,19 +797,19 @@ describe(LibraryService.name, () => { }), ).resolves.toEqual( expect.objectContaining({ - id: libraryStub.externalLibrary1.id, - name: libraryStub.externalLibrary1.name, - ownerId: libraryStub.externalLibrary1.ownerId, + id: library.id, + name: library.name, + ownerId: library.ownerId, assetCount: 0, importPaths: [], exclusionPatterns: [], - createdAt: libraryStub.externalLibrary1.createdAt, - updatedAt: libraryStub.externalLibrary1.updatedAt, + createdAt: library.createdAt, + updatedAt: library.updatedAt, refreshedAt: null, }), ); - expect(libraryMock.create).toHaveBeenCalledWith( + expect(mocks.library.create).toHaveBeenCalledWith( expect.objectContaining({ name: expect.any(String), importPaths: [], @@ -776,50 +822,60 @@ describe(LibraryService.name, () => { describe('getAll', () => { it('should get all libraries', async () => { - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); - await expect(sut.getAll()).resolves.toEqual([expect.objectContaining({ id: libraryStub.externalLibrary1.id })]); + const library = factory.library(); + + mocks.library.getAll.mockResolvedValue([library]); + + await expect(sut.getAll()).resolves.toEqual([expect.objectContaining({ id: library.id })]); }); }); describe('handleQueueCleanup', () => { it('should queue cleanup jobs', async () => { - libraryMock.getAllDeleted.mockResolvedValue([libraryStub.externalLibrary1, libraryStub.externalLibrary2]); + const library1 = factory.library({ deletedAt: new Date() }); + const library2 = factory.library({ deletedAt: new Date() }); + + mocks.library.getAllDeleted.mockResolvedValue([library1, library2]); await expect(sut.handleQueueCleanup()).resolves.toBe(JobStatus.SUCCESS); - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { name: JobName.LIBRARY_DELETE, data: { id: libraryStub.externalLibrary1.id } }, - { name: JobName.LIBRARY_DELETE, data: { id: libraryStub.externalLibrary2.id } }, + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { name: JobName.LIBRARY_DELETE, data: { id: library1.id } }, + { name: JobName.LIBRARY_DELETE, data: { id: library2.id } }, ]); }); }); describe('update', () => { beforeEach(async () => { - libraryMock.getAll.mockResolvedValue([]); + mocks.library.getAll.mockResolvedValue([]); + mocks.cron.create.mockResolvedValue(); await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchEnabled as SystemConfig }); }); it('should throw an error if an import path is invalid', async () => { - libraryMock.update.mockResolvedValue(libraryStub.externalLibrary1); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + const library = factory.library(); + + mocks.library.update.mockResolvedValue(library); + mocks.library.get.mockResolvedValue(library); await expect(sut.update('library-id', { importPaths: ['foo/bar'] })).rejects.toBeInstanceOf(BadRequestException); - expect(libraryMock.update).not.toHaveBeenCalled(); + + expect(mocks.library.update).not.toHaveBeenCalled(); }); it('should update library', async () => { - libraryMock.update.mockResolvedValue(libraryStub.externalLibrary1); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - storageMock.stat.mockResolvedValue({ isDirectory: () => true } as Stats); - storageMock.checkFileExists.mockResolvedValue(true); + const library = factory.library(); + + mocks.library.update.mockResolvedValue(library); + mocks.library.get.mockResolvedValue(library); + mocks.storage.stat.mockResolvedValue({ isDirectory: () => true } as Stats); + mocks.storage.checkFileExists.mockResolvedValue(true); const cwd = process.cwd(); - await expect(sut.update('library-id', { importPaths: [`${cwd}/foo/bar`] })).resolves.toEqual( - mapLibrary(libraryStub.externalLibrary1), - ); - expect(libraryMock.update).toHaveBeenCalledWith( + await expect(sut.update('library-id', { importPaths: [`${cwd}/foo/bar`] })).resolves.toEqual(mapLibrary(library)); + expect(mocks.library.update).toHaveBeenCalledWith( 'library-id', expect.objectContaining({ importPaths: [`${cwd}/foo/bar`] }), ); @@ -839,126 +895,134 @@ describe(LibraryService.name, () => { describe('watching disabled', () => { beforeEach(async () => { + mocks.cron.create.mockResolvedValue(); + await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchDisabled as SystemConfig }); }); it('should not watch library', async () => { - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + + mocks.library.getAll.mockResolvedValue([library]); await sut.watchAll(); - expect(storageMock.watch).not.toHaveBeenCalled(); + expect(mocks.storage.watch).not.toHaveBeenCalled(); }); }); describe('watching enabled', () => { beforeEach(async () => { - libraryMock.getAll.mockResolvedValue([]); + mocks.library.getAll.mockResolvedValue([]); + mocks.cron.create.mockResolvedValue(); + await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchEnabled as SystemConfig }); }); it('should watch library', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + + mocks.library.get.mockResolvedValue(library); + mocks.library.getAll.mockResolvedValue([library]); await sut.watchAll(); - expect(storageMock.watch).toHaveBeenCalledWith( - libraryStub.externalLibraryWithImportPaths1.importPaths, - expect.anything(), - expect.anything(), - ); + expect(mocks.storage.watch).toHaveBeenCalledWith(library.importPaths, expect.anything(), expect.anything()); }); it('should watch and unwatch library', async () => { - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + + mocks.library.getAll.mockResolvedValue([library]); + mocks.library.get.mockResolvedValue(library); const mockClose = vitest.fn(); - storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); + mocks.storage.watch.mockImplementation(makeMockWatcher({ close: mockClose })); await sut.watchAll(); - await sut.unwatch(libraryStub.externalLibraryWithImportPaths1.id); + await sut.unwatch(library.id); expect(mockClose).toHaveBeenCalled(); }); it('should not watch library without import paths', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); + const library = factory.library(); + + mocks.library.get.mockResolvedValue(library); + mocks.library.getAll.mockResolvedValue([library]); await sut.watchAll(); - expect(storageMock.watch).not.toHaveBeenCalled(); + expect(mocks.storage.watch).not.toHaveBeenCalled(); }); it('should handle a new file event', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - storageMock.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/foo/photo.jpg' }] })); + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + + mocks.library.get.mockResolvedValue(library); + mocks.library.getAll.mockResolvedValue([library]); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.storage.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/foo/photo.jpg' }] })); await sut.watchAll(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { - name: JobName.LIBRARY_SYNC_FILE, - data: { - id: libraryStub.externalLibraryWithImportPaths1.id, - assetPath: '/foo/photo.jpg', - ownerId: libraryStub.externalLibraryWithImportPaths1.owner.id, - }, + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.LIBRARY_SYNC_FILES, + data: { + libraryId: library.id, + paths: ['/foo/photo.jpg'], }, - ]); - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { name: JobName.LIBRARY_SYNC_ASSET, data: expect.objectContaining({ id: assetStub.image.id }) }, - ]); + }); }); it('should handle a file change event', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - storageMock.watch.mockImplementation( + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + + mocks.library.get.mockResolvedValue(library); + mocks.library.getAll.mockResolvedValue([library]); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.storage.watch.mockImplementation( makeMockWatcher({ items: [{ event: 'change', value: '/foo/photo.jpg' }] }), ); await sut.watchAll(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { - name: JobName.LIBRARY_SYNC_FILE, - data: { - id: libraryStub.externalLibraryWithImportPaths1.id, - assetPath: '/foo/photo.jpg', - ownerId: libraryStub.externalLibraryWithImportPaths1.owner.id, - }, + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.LIBRARY_SYNC_FILES, + data: { + libraryId: library.id, + paths: ['/foo/photo.jpg'], }, - ]); - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { name: JobName.LIBRARY_SYNC_ASSET, data: expect.objectContaining({ id: assetStub.image.id }) }, - ]); + }); }); it('should handle a file unlink event', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - storageMock.watch.mockImplementation( - makeMockWatcher({ items: [{ event: 'unlink', value: '/foo/photo.jpg' }] }), + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + + mocks.library.get.mockResolvedValue(library); + mocks.library.getAll.mockResolvedValue([library]); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.storage.watch.mockImplementation( + makeMockWatcher({ items: [{ event: 'unlink', value: assetStub.image.originalPath }] }), ); await sut.watchAll(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { name: JobName.LIBRARY_SYNC_ASSET, data: expect.objectContaining({ id: assetStub.image.id }) }, - ]); + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.LIBRARY_ASSET_REMOVAL, + data: { + libraryId: library.id, + paths: [assetStub.image.originalPath], + }, + }); }); it('should handle an error event', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.external); - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); - storageMock.watch.mockImplementation( + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + + mocks.library.get.mockResolvedValue(library); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.external); + mocks.library.getAll.mockResolvedValue([library]); + mocks.storage.watch.mockImplementation( makeMockWatcher({ items: [{ event: 'error', value: 'Error!' }], }), @@ -967,57 +1031,64 @@ describe(LibraryService.name, () => { await expect(sut.watchAll()).resolves.toBeUndefined(); }); - it('should ignore unknown extensions', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); - storageMock.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/foo/photo.jpg' }] })); + it('should not import a file with unknown extension', async () => { + const library = factory.library({ importPaths: ['/foo', '/bar'] }); + + mocks.library.get.mockResolvedValue(library); + mocks.library.getAll.mockResolvedValue([library]); + mocks.storage.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/foo/photo.xyz' }] })); await sut.watchAll(); - expect(jobMock.queue).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); }); it('should ignore excluded paths', async () => { - libraryMock.get.mockResolvedValue(libraryStub.patternPath); - libraryMock.getAll.mockResolvedValue([libraryStub.patternPath]); - storageMock.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/dir1/photo.txt' }] })); + const library = factory.library({ importPaths: ['/xyz', '/asdf'], exclusionPatterns: ['**/dir1/**'] }); + + mocks.library.get.mockResolvedValue(library); + mocks.library.getAll.mockResolvedValue([library]); + mocks.storage.watch.mockImplementation( + makeMockWatcher({ items: [{ event: 'add', value: '/dir1/photo.txt' }] }), + ); await sut.watchAll(); - expect(jobMock.queue).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); }); it('should ignore excluded paths without case sensitivity', async () => { - libraryMock.get.mockResolvedValue(libraryStub.patternPath); - libraryMock.getAll.mockResolvedValue([libraryStub.patternPath]); - storageMock.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/DIR1/photo.txt' }] })); + const library = factory.library({ + importPaths: ['/xyz', '/asdf'], + exclusionPatterns: ['**/dir1/**'], + }); + + mocks.library.get.mockResolvedValue(library); + mocks.library.getAll.mockResolvedValue([library]); + mocks.storage.watch.mockImplementation( + makeMockWatcher({ items: [{ event: 'add', value: '/DIR1/photo.txt' }] }), + ); await sut.watchAll(); - expect(jobMock.queue).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); }); }); }); describe('teardown', () => { it('should tear down all watchers', async () => { - libraryMock.getAll.mockResolvedValue([ - libraryStub.externalLibraryWithImportPaths1, - libraryStub.externalLibraryWithImportPaths2, - ]); + const library1 = factory.library({ importPaths: ['/foo', '/bar'] }); + const library2 = factory.library({ importPaths: ['/xyz', '/asdf'] }); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - - libraryMock.get.mockImplementation((id) => - Promise.resolve( - [libraryStub.externalLibraryWithImportPaths1, libraryStub.externalLibraryWithImportPaths2].find( - (library) => library.id === id, - ), - ), + mocks.library.getAll.mockResolvedValue([library1, library2]); + mocks.library.get.mockImplementation((id) => + Promise.resolve([library1, library2].find((library) => library.id === id)), ); const mockClose = vitest.fn(); - storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); + mocks.storage.watch.mockImplementation(makeMockWatcher({ close: mockClose })); + mocks.cron.create.mockResolvedValue(); await sut.onConfigInit({ newConfig: systemConfigStub.libraryWatchEnabled as SystemConfig }); await sut.onShutdown(); @@ -1028,92 +1099,62 @@ describe(LibraryService.name, () => { describe('handleDeleteLibrary', () => { it('should delete an empty library', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - assetMock.getAll.mockResolvedValue({ items: [], hasNextPage: false }); + const library = factory.library(); - await expect(sut.handleDeleteLibrary({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); - expect(libraryMock.delete).toHaveBeenCalled(); + mocks.library.get.mockResolvedValue(library); + mocks.library.streamAssetIds.mockReturnValue(makeStream([])); + + await expect(sut.handleDeleteLibrary({ id: library.id })).resolves.toBe(JobStatus.SUCCESS); + + expect(mocks.library.delete).toHaveBeenCalled(); }); it('should delete all assets in a library', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - assetMock.getAll.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); + const library = factory.library(); - assetMock.getById.mockResolvedValue(assetStub.image1); + mocks.library.get.mockResolvedValue(library); + mocks.library.streamAssetIds.mockReturnValue(makeStream([assetStub.image1])); - await expect(sut.handleDeleteLibrary({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); + mocks.asset.getById.mockResolvedValue(assetStub.image1); + + await expect(sut.handleDeleteLibrary({ id: library.id })).resolves.toBe(JobStatus.SUCCESS); }); }); describe('queueScan', () => { it('should queue a library scan', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + const library = factory.library(); - await sut.queueScan(libraryStub.externalLibrary1.id); + mocks.library.get.mockResolvedValue(library); - expect(jobMock.queue.mock.calls).toEqual([ - [ - { - name: JobName.LIBRARY_QUEUE_SYNC_FILES, - data: { - id: libraryStub.externalLibrary1.id, - }, - }, - ], - [ - { - name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, - data: { - id: libraryStub.externalLibrary1.id, - }, - }, - ], - ]); + await sut.queueScan(library.id); + + expect(mocks.job.queue).toHaveBeenCalledTimes(2); + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.LIBRARY_QUEUE_SYNC_FILES, + data: { id: library.id }, + }); + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, + data: { id: library.id }, + }); }); }); describe('handleQueueAllScan', () => { it('should queue the refresh job', async () => { - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); + const library = factory.library(); - await expect(sut.handleQueueSyncAll()).resolves.toBe(JobStatus.SUCCESS); + mocks.library.getAll.mockResolvedValue([library]); - expect(jobMock.queue.mock.calls).toEqual([ - [ - { - name: JobName.LIBRARY_QUEUE_CLEANUP, - data: {}, - }, - ], - ]); - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { - name: JobName.LIBRARY_QUEUE_SYNC_FILES, - data: { - id: libraryStub.externalLibrary1.id, - }, - }, - ]); - }); - }); + await expect(sut.handleQueueScanAll()).resolves.toBe(JobStatus.SUCCESS); - describe('handleQueueAssetOfflineCheck', () => { - it('should queue removal jobs', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - assetMock.getAll.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); - assetMock.getById.mockResolvedValue(assetStub.image1); - - await expect(sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); - - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { - name: JobName.LIBRARY_SYNC_ASSET, - data: { - id: assetStub.image1.id, - importPaths: libraryStub.externalLibrary1.importPaths, - exclusionPatterns: libraryStub.externalLibrary1.exclusionPatterns, - }, - }, + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.LIBRARY_QUEUE_CLEANUP, + data: {}, + }); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { name: JobName.LIBRARY_QUEUE_SYNC_FILES, data: { id: library.id } }, ]); }); }); @@ -1124,11 +1165,11 @@ describe(LibraryService.name, () => { }); it('should validate directory', async () => { - storageMock.stat.mockResolvedValue({ + mocks.storage.stat.mockResolvedValue({ isDirectory: () => true, } as Stats); - storageMock.checkFileExists.mockResolvedValue(true); + mocks.storage.checkFileExists.mockResolvedValue(true); await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({ importPaths: [ @@ -1142,7 +1183,7 @@ describe(LibraryService.name, () => { }); it('should detect when path does not exist', async () => { - storageMock.stat.mockImplementation(() => { + mocks.storage.stat.mockImplementation(() => { const error = { code: 'ENOENT' } as any; throw error; }); @@ -1159,7 +1200,7 @@ describe(LibraryService.name, () => { }); it('should detect when path is not a directory', async () => { - storageMock.stat.mockResolvedValue({ + mocks.storage.stat.mockResolvedValue({ isDirectory: () => false, } as Stats); @@ -1175,7 +1216,7 @@ describe(LibraryService.name, () => { }); it('should return an unknown exception from stat', async () => { - storageMock.stat.mockImplementation(() => { + mocks.storage.stat.mockImplementation(() => { throw new Error('Unknown error'); }); @@ -1191,11 +1232,11 @@ describe(LibraryService.name, () => { }); it('should detect when access rights are missing', async () => { - storageMock.stat.mockResolvedValue({ + mocks.storage.stat.mockResolvedValue({ isDirectory: () => true, } as Stats); - storageMock.checkFileExists.mockResolvedValue(false); + mocks.storage.checkFileExists.mockResolvedValue(false); await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({ importPaths: [ @@ -1223,28 +1264,26 @@ describe(LibraryService.name, () => { }); it('should detect when import path is in immich media folder', async () => { - storageMock.stat.mockResolvedValue({ isDirectory: () => true } as Stats); - const cwd = process.cwd(); + const importPaths = ['upload/thumbs', `${process.cwd()}/xyz`, 'upload/library']; + const library = factory.library({ importPaths }); - const validImport = `${cwd}/${libraryStub.hasImmichPaths.importPaths[1]}`; - storageMock.checkFileExists.mockImplementation((importPath) => Promise.resolve(importPath === validImport)); + mocks.storage.stat.mockResolvedValue({ isDirectory: () => true } as Stats); - const pathStubs = libraryStub.hasImmichPaths.importPaths; - const importPaths = [pathStubs[0], validImport, pathStubs[2]]; + mocks.storage.checkFileExists.mockImplementation((importPath) => Promise.resolve(importPath === importPaths[1])); - await expect(sut.validate('library-id', { importPaths })).resolves.toEqual({ + await expect(sut.validate(library.id, { importPaths })).resolves.toEqual({ importPaths: [ { - importPath: libraryStub.hasImmichPaths.importPaths[0], + importPath: importPaths[0], isValid: false, message: 'Cannot use media upload folder for external libraries', }, { - importPath: validImport, + importPath: importPaths[1], isValid: true, }, { - importPath: libraryStub.hasImmichPaths.importPaths[2], + importPath: importPaths[2], isValid: false, message: 'Cannot use media upload folder for external libraries', }, diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index dca1dec9e2..1039cff761 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -1,7 +1,9 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { R_OK } from 'node:constants'; +import { Stats } from 'node:fs'; import path, { basename, isAbsolute, parse } from 'node:path'; import picomatch from 'picomatch'; +import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent, OnJob } from 'src/decorators'; import { @@ -15,15 +17,13 @@ import { ValidateLibraryResponseDto, } from 'src/dtos/library.dto'; import { AssetEntity } from 'src/entities/asset.entity'; -import { LibraryEntity } from 'src/entities/library.entity'; -import { AssetType, ImmichWorker } from 'src/enum'; -import { DatabaseLock } from 'src/interfaces/database.interface'; -import { ArgOf } from 'src/interfaces/event.interface'; -import { JobName, JobOf, JOBS_LIBRARY_PAGINATION_SIZE, JobStatus, QueueName } from 'src/interfaces/job.interface'; +import { AssetStatus, AssetType, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum'; +import { ArgOf } from 'src/repositories/event.repository'; +import { AssetSyncResult } from 'src/repositories/library.repository'; 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 { usePagination } from 'src/utils/pagination'; @Injectable() export class LibraryService extends BaseService { @@ -47,7 +47,7 @@ export class LibraryService extends BaseService { name: 'libraryScan', expression: scan.cronExpression, onTick: () => - handlePromiseError(this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ALL }), this.logger), + handlePromiseError(this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL }), this.logger), start: scan.enabled, }); } @@ -98,6 +98,26 @@ export class LibraryService extends BaseService { let _resolve: () => void; const ready$ = new Promise((resolve) => (_resolve = resolve)); + const handler = async (event: string, path: string) => { + if (matcher(path)) { + this.logger.debug(`File ${event} event received for ${path} in library ${library.id}}`); + await this.jobRepository.queue({ + name: JobName.LIBRARY_SYNC_FILES, + data: { libraryId: library.id, paths: [path] }, + }); + } else { + this.logger.verbose(`Ignoring file ${event} event for ${path} in library ${library.id}`); + } + }; + + const deletionHandler = async (path: string) => { + this.logger.debug(`File unlink event received for ${path} in library ${library.id}}`); + await this.jobRepository.queue({ + name: JobName.LIBRARY_ASSET_REMOVAL, + data: { libraryId: library.id, paths: [path] }, + }); + }; + this.watchers[id] = this.storageRepository.watch( library.importPaths, { @@ -107,43 +127,13 @@ export class LibraryService extends BaseService { { onReady: () => _resolve(), onAdd: (path) => { - const handler = async () => { - this.logger.debug(`File add event received for ${path} in library ${library.id}}`); - if (matcher(path)) { - const asset = await this.assetRepository.getByLibraryIdAndOriginalPath(library.id, path); - if (asset) { - await this.syncAssets(library, [asset.id]); - } - if (matcher(path)) { - await this.syncFiles(library, [path]); - } - } - }; - return handlePromiseError(handler(), this.logger); + return handlePromiseError(handler('add', path), this.logger); }, onChange: (path) => { - const handler = async () => { - this.logger.debug(`Detected file change for ${path} in library ${library.id}`); - const asset = await this.assetRepository.getByLibraryIdAndOriginalPath(library.id, path); - if (asset) { - await this.syncAssets(library, [asset.id]); - } - if (matcher(path)) { - // Note: if the changed file was not previously imported, it will be imported now. - await this.syncFiles(library, [path]); - } - }; - return handlePromiseError(handler(), this.logger); + return handlePromiseError(handler('change', path), this.logger); }, onUnlink: (path) => { - const handler = async () => { - this.logger.debug(`Detected deleted file at ${path} in library ${library.id}`); - const asset = await this.assetRepository.getByLibraryIdAndOriginalPath(library.id, path); - if (asset) { - await this.syncAssets(library, [asset.id]); - } - }; - return handlePromiseError(handler(), this.logger); + return handlePromiseError(deletionHandler(path), this.logger); }, onError: (error) => { this.logger.error(`Library watcher for library ${library.id} encountered error: ${error}`); @@ -210,11 +200,17 @@ export class LibraryService extends BaseService { @OnJob({ name: JobName.LIBRARY_QUEUE_CLEANUP, queue: QueueName.LIBRARY }) async handleQueueCleanup(): Promise { - this.logger.debug('Cleaning up any pending library deletions'); - const pendingDeletion = await this.libraryRepository.getAllDeleted(); - await this.jobRepository.queueAll( - pendingDeletion.map((libraryToDelete) => ({ name: JobName.LIBRARY_DELETE, data: { id: libraryToDelete.id } })), - ); + this.logger.log('Checking for any libraries pending deletion...'); + const pendingDeletions = await this.libraryRepository.getAllDeleted(); + if (pendingDeletions.length > 0) { + const libraryString = pendingDeletions.length === 1 ? 'library' : 'libraries'; + this.logger.log(`Found ${pendingDeletions.length} ${libraryString} pending deletion, cleaning up...`); + + await this.jobRepository.queueAll( + pendingDeletions.map((libraryToDelete) => ({ name: JobName.LIBRARY_DELETE, data: { id: libraryToDelete.id } })), + ); + } + return JobStatus.SUCCESS; } @@ -223,31 +219,43 @@ export class LibraryService extends BaseService { ownerId: dto.ownerId, name: dto.name ?? 'New External Library', importPaths: dto.importPaths ?? [], - exclusionPatterns: dto.exclusionPatterns ?? ['**/@eaDir/**', '**/._*'], + exclusionPatterns: dto.exclusionPatterns ?? ['**/@eaDir/**', '**/._*', '**/#recycle/**', '**/#snapshot/**'], }); return mapLibrary(library); } - private async syncFiles({ id, ownerId }: LibraryEntity, assetPaths: string[]) { - await this.jobRepository.queueAll( - assetPaths.map((assetPath) => ({ - name: JobName.LIBRARY_SYNC_FILE, - data: { - id, - assetPath, - ownerId, - }, - })), - ); - } + @OnJob({ name: JobName.LIBRARY_SYNC_FILES, queue: QueueName.LIBRARY }) + async handleSyncFiles(job: JobOf): Promise { + const library = await this.libraryRepository.get(job.libraryId); + // We need to check if the library still exists as it could have been deleted after the scan was queued + if (!library) { + this.logger.debug(`Library ${job.libraryId} not found, skipping file import`); + return JobStatus.FAILED; + } else if (library.deletedAt) { + this.logger.debug(`Library ${job.libraryId} is deleted, won't import assets into it`); + return JobStatus.FAILED; + } - private async syncAssets({ importPaths, exclusionPatterns }: LibraryEntity, assetIds: string[]) { - await this.jobRepository.queueAll( - assetIds.map((assetId) => ({ - name: JobName.LIBRARY_SYNC_ASSET, - data: { id: assetId, importPaths, exclusionPatterns }, - })), - ); + const assetImports = job.paths.map((assetPath) => this.processEntity(assetPath, library.ownerId, job.libraryId)); + + const assetIds: string[] = []; + + for (let i = 0; i < assetImports.length; i += 5000) { + // Chunk the imports to avoid the postgres limit of max parameters at once + const chunk = assetImports.slice(i, i + 5000); + await this.assetRepository.createAll(chunk).then((assets) => assetIds.push(...assets.map((asset) => asset.id))); + } + + const progressMessage = + job.progressCounter && job.totalAssets + ? `(${job.progressCounter} of ${job.totalAssets})` + : `(${job.progressCounter} done so far)`; + + this.logger.log(`Imported ${assetIds.length} ${progressMessage} file(s) into library ${job.libraryId}`); + + await this.queuePostSyncJobs(assetIds); + + return JobStatus.SUCCESS; } private async validateImportPath(importPath: string): Promise { @@ -330,125 +338,103 @@ export class LibraryService extends BaseService { async handleDeleteLibrary(job: JobOf): Promise { const libraryId = job.id; - const assetPagination = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) => - this.assetRepository.getAll(pagination, { libraryId, withDeleted: true }), - ); + await this.assetRepository.updateByLibraryId(libraryId, { deletedAt: new Date() }); let assetsFound = false; + let chunk: string[] = []; + + const queueChunk = async () => { + if (chunk.length > 0) { + assetsFound = true; + this.logger.debug(`Queueing deletion of ${chunk.length} asset(s) in library ${libraryId}`); + await this.jobRepository.queueAll( + chunk.map((id) => ({ name: JobName.ASSET_DELETION, data: { id, deleteOnDisk: false } })), + ); + chunk = []; + } + }; this.logger.debug(`Will delete all assets in library ${libraryId}`); - for await (const assets of assetPagination) { - if (assets.length > 0) { - assetsFound = true; - } + const assets = this.libraryRepository.streamAssetIds(libraryId); + for await (const asset of assets) { + chunk.push(asset.id); - this.logger.debug(`Queueing deletion of ${assets.length} asset(s) in library ${libraryId}`); - await this.jobRepository.queueAll( - assets.map((asset) => ({ - name: JobName.ASSET_DELETION, - data: { - id: asset.id, - deleteOnDisk: false, - }, - })), - ); + if (chunk.length >= JOBS_LIBRARY_PAGINATION_SIZE) { + await queueChunk(); + } } + await queueChunk(); + if (!assetsFound) { this.logger.log(`Deleting library ${libraryId}`); await this.libraryRepository.delete(libraryId); } + return JobStatus.SUCCESS; } - @OnJob({ name: JobName.LIBRARY_SYNC_FILE, queue: QueueName.LIBRARY }) - async handleSyncFile(job: JobOf): Promise { - // Only needs to handle new assets - const assetPath = path.normalize(job.assetPath); + private processEntity(filePath: string, ownerId: string, libraryId: string) { + const assetPath = path.normalize(filePath); - let asset = await this.assetRepository.getByLibraryIdAndOriginalPath(job.id, assetPath); - if (asset) { - return JobStatus.SKIPPED; - } - - let stat; - try { - stat = await this.storageRepository.stat(assetPath); - } catch (error: any) { - if (error.code === 'ENOENT') { - this.logger.error(`File not found: ${assetPath}`); - return JobStatus.SKIPPED; - } - this.logger.error(`Error reading file: ${assetPath}. Error: ${error}`); - return JobStatus.FAILED; - } - - this.logger.log(`Importing new library asset: ${assetPath}`); - - const library = await this.libraryRepository.get(job.id, true); - if (!library || library.deletedAt) { - this.logger.error('Cannot import asset into deleted library'); - return JobStatus.FAILED; - } - - // TODO: device asset id is deprecated, remove it - const deviceAssetId = `${basename(assetPath)}`.replaceAll(/\s+/g, ''); - - const pathHash = this.cryptoRepository.hashSha1(`path:${assetPath}`); - - const assetType = mimeTypes.isVideo(assetPath) ? AssetType.VIDEO : AssetType.IMAGE; - - const mtime = stat.mtime; - - asset = await this.assetRepository.create({ - ownerId: job.ownerId, - libraryId: job.id, - checksum: pathHash, + return { + ownerId, + libraryId, + checksum: this.cryptoRepository.hashSha1(`path:${assetPath}`), originalPath: assetPath, - deviceAssetId, + + fileCreatedAt: null, + fileModifiedAt: null, + localDateTime: null, + // TODO: device asset id is deprecated, remove it + deviceAssetId: `${basename(assetPath)}`.replaceAll(/\s+/g, ''), deviceId: 'Library Import', - fileCreatedAt: mtime, - fileModifiedAt: mtime, - localDateTime: mtime, - type: assetType, + type: mimeTypes.isVideo(assetPath) ? AssetType.VIDEO : AssetType.IMAGE, originalFileName: parse(assetPath).base, isExternal: true, - }); - - await this.queuePostSyncJobs(asset); - - return JobStatus.SUCCESS; + livePhotoVideoId: null, + }; } - async queuePostSyncJobs(asset: AssetEntity) { - this.logger.debug(`Queueing metadata extraction for: ${asset.originalPath}`); + async queuePostSyncJobs(assetIds: string[]) { + this.logger.debug(`Queuing sidecar discovery for ${assetIds.length} asset(s)`); // We queue a sidecar discovery which, in turn, queues metadata extraction - await this.jobRepository.queue({ - name: JobName.SIDECAR_DISCOVERY, - data: { id: asset.id, source: 'upload' }, - }); + await this.jobRepository.queueAll( + assetIds.map((assetId) => ({ + name: JobName.SIDECAR_DISCOVERY, + data: { id: assetId, source: 'upload' }, + })), + ); } async queueScan(id: string) { await this.findOrFail(id); + this.logger.log(`Starting to scan library ${id}`); + await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_FILES, data: { id, }, }); + await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, data: { id } }); } - @OnJob({ name: JobName.LIBRARY_QUEUE_SYNC_ALL, queue: QueueName.LIBRARY }) - async handleQueueSyncAll(): Promise { - this.logger.debug(`Refreshing all external libraries`); + async queueScanAll() { + await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: {} }); + } + + @OnJob({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, queue: QueueName.LIBRARY }) + async handleQueueScanAll(): Promise { + this.logger.log(`Initiating scan of all external libraries...`); await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_CLEANUP, data: {} }); const libraries = await this.libraryRepository.getAll(true); + await this.jobRepository.queueAll( libraries.map((library) => ({ name: JobName.LIBRARY_QUEUE_SYNC_FILES, @@ -465,65 +451,141 @@ export class LibraryService extends BaseService { }, })), ); + return JobStatus.SUCCESS; } - @OnJob({ name: JobName.LIBRARY_SYNC_ASSET, queue: QueueName.LIBRARY }) - async handleSyncAsset(job: JobOf): Promise { - const asset = await this.assetRepository.getById(job.id); - if (!asset) { - return JobStatus.SKIPPED; - } + @OnJob({ name: JobName.LIBRARY_SYNC_ASSETS, queue: QueueName.LIBRARY }) + async handleSyncAssets(job: JobOf): Promise { + const assets = await this.assetRepository.getByIds(job.assetIds); - const markOffline = async (explanation: string) => { - if (!asset.isOffline) { - this.logger.debug(`${explanation}, removing: ${asset.originalPath}`); - await this.assetRepository.updateAll([asset.id], { isOffline: true, deletedAt: new Date() }); + const assetIdsToOffline: string[] = []; + const trashedAssetIdsToOffline: string[] = []; + const assetIdsToOnline: string[] = []; + const trashedAssetIdsToOnline: string[] = []; + const assetIdsToUpdate: string[] = []; + + this.logger.debug(`Checking batch of ${assets.length} existing asset(s) in library ${job.libraryId}`); + + const stats = await Promise.all( + assets.map((asset) => this.storageRepository.stat(asset.originalPath).catch(() => null)), + ); + + for (let i = 0; i < assets.length; i++) { + const asset = assets[i]; + const stat = stats[i]; + const action = this.checkExistingAsset(asset, stat); + switch (action) { + case AssetSyncResult.OFFLINE: { + if (asset.status === AssetStatus.TRASHED) { + trashedAssetIdsToOffline.push(asset.id); + } else { + assetIdsToOffline.push(asset.id); + } + break; + } + case AssetSyncResult.UPDATE: { + assetIdsToUpdate.push(asset.id); + break; + } + case AssetSyncResult.CHECK_OFFLINE: { + const isInImportPath = job.importPaths.find((path) => asset.originalPath.startsWith(path)); + + if (!isInImportPath) { + this.logger.verbose( + `Offline asset ${asset.originalPath} is still not in any import path, keeping offline in library ${job.libraryId}`, + ); + break; + } + + const isExcluded = job.exclusionPatterns.some((pattern) => picomatch.isMatch(asset.originalPath, pattern)); + + if (!isExcluded) { + this.logger.debug(`Offline asset ${asset.originalPath} is now online in library ${job.libraryId}`); + if (asset.status === AssetStatus.TRASHED) { + trashedAssetIdsToOnline.push(asset.id); + } else { + assetIdsToOnline.push(asset.id); + } + break; + } + + this.logger.verbose( + `Offline asset ${asset.originalPath} is in an import path but still covered by exclusion pattern, keeping offline in library ${job.libraryId}`, + ); + + break; + } } - }; - - const isInPath = job.importPaths.find((path) => asset.originalPath.startsWith(path)); - if (!isInPath) { - await markOffline('Asset is no longer in an import path'); - return JobStatus.SUCCESS; } - const isExcluded = job.exclusionPatterns.some((pattern) => picomatch.isMatch(asset.originalPath, pattern)); - if (isExcluded) { - await markOffline('Asset is covered by an exclusion pattern'); - return JobStatus.SUCCESS; + const promises = []; + if (assetIdsToOffline.length > 0) { + promises.push(this.assetRepository.updateAll(assetIdsToOffline, { isOffline: true, deletedAt: new Date() })); } - let stat; - try { - stat = await this.storageRepository.stat(asset.originalPath); - } catch { - await markOffline('Asset is no longer on disk or is inaccessible because of permissions'); - return JobStatus.SUCCESS; + if (trashedAssetIdsToOffline.length > 0) { + promises.push(this.assetRepository.updateAll(trashedAssetIdsToOffline, { isOffline: true })); } - const mtime = stat.mtime; - const isAssetModified = mtime.toISOString() !== asset.fileModifiedAt.toISOString(); - - if (asset.isOffline || isAssetModified) { - this.logger.debug(`Asset was offline or modified, updating asset record ${asset.originalPath}`); - //TODO: When we have asset status, we need to leave deletedAt as is when status is trashed - await this.assetRepository.updateAll([asset.id], { - isOffline: false, - deletedAt: null, - fileCreatedAt: mtime, - fileModifiedAt: mtime, - originalFileName: parse(asset.originalPath).base, - }); + if (assetIdsToOnline.length > 0) { + promises.push(this.assetRepository.updateAll(assetIdsToOnline, { isOffline: false, deletedAt: null })); } - if (isAssetModified) { - this.logger.debug(`Asset was modified, queuing metadata extraction for: ${asset.originalPath}`); - await this.queuePostSyncJobs(asset); + if (trashedAssetIdsToOnline.length > 0) { + promises.push(this.assetRepository.updateAll(trashedAssetIdsToOnline, { isOffline: false })); } + + if (assetIdsToUpdate.length > 0) { + promises.push(this.queuePostSyncJobs(assetIdsToUpdate)); + } + + await Promise.all(promises); + + const remainingCount = assets.length - assetIdsToOffline.length - assetIdsToUpdate.length - assetIdsToOnline.length; + const cumulativePercentage = ((100 * job.progressCounter) / job.totalAssets).toFixed(1); + this.logger.log( + `Checked existing asset(s): ${assetIdsToOffline.length + trashedAssetIdsToOffline.length} offlined, ${assetIdsToOnline.length + trashedAssetIdsToOnline.length} onlined, ${assetIdsToUpdate.length} updated, ${remainingCount} unchanged of current batch of ${assets.length} (Total progress: ${job.progressCounter} of ${job.totalAssets}, ${cumulativePercentage} %) in library ${job.libraryId}.`, + ); + return JobStatus.SUCCESS; } + private checkExistingAsset(asset: AssetEntity, stat: Stats | null): AssetSyncResult { + if (!stat) { + // File not found on disk or permission error + if (asset.isOffline) { + this.logger.verbose( + `Asset ${asset.originalPath} is still not accessible, keeping offline in library ${asset.libraryId}`, + ); + return AssetSyncResult.DO_NOTHING; + } + + this.logger.debug( + `Asset ${asset.originalPath} is no longer on disk or is inaccessible because of permissions, marking offline in library ${asset.libraryId}`, + ); + return AssetSyncResult.OFFLINE; + } + + if (asset.isOffline && asset.status !== AssetStatus.DELETED) { + // Only perform the expensive check if the asset is offline + return AssetSyncResult.CHECK_OFFLINE; + } + + if ( + !asset.fileCreatedAt || + !asset.localDateTime || + !asset.fileModifiedAt || + stat.mtime.valueOf() !== asset.fileModifiedAt.valueOf() + ) { + this.logger.verbose(`Asset ${asset.originalPath} needs metadata extraction in library ${asset.libraryId}`); + + return AssetSyncResult.UPDATE; + } + + return AssetSyncResult.DO_NOTHING; + } + @OnJob({ name: JobName.LIBRARY_QUEUE_SYNC_FILES, queue: QueueName.LIBRARY }) async handleQueueSyncFiles(job: JobOf): Promise { const library = await this.libraryRepository.get(job.id); @@ -532,7 +594,7 @@ export class LibraryService extends BaseService { return JobStatus.SKIPPED; } - this.logger.log(`Refreshing library ${library.id} for new assets`); + this.logger.debug(`Validating import paths for library ${library.id}...`); const validImportPaths: string[] = []; @@ -547,35 +609,67 @@ export class LibraryService extends BaseService { if (validImportPaths.length === 0) { this.logger.warn(`No valid import paths found for library ${library.id}`); + + return JobStatus.SKIPPED; } - const assetsOnDisk = this.storageRepository.walk({ + const pathsOnDisk = this.storageRepository.walk({ pathsToCrawl: validImportPaths, includeHidden: false, exclusionPatterns: library.exclusionPatterns, take: JOBS_LIBRARY_PAGINATION_SIZE, }); - let count = 0; + let importCount = 0; + let crawlCount = 0; - for await (const assetBatch of assetsOnDisk) { - count += assetBatch.length; - this.logger.debug(`Discovered ${count} asset(s) on disk for library ${library.id}...`); - await this.syncFiles(library, assetBatch); - this.logger.verbose(`Queued scan of ${assetBatch.length} crawled asset(s) in library ${library.id}...`); + this.logger.log(`Starting disk crawl of ${validImportPaths.length} import path(s) for library ${library.id}...`); + + for await (const pathBatch of pathsOnDisk) { + crawlCount += pathBatch.length; + const paths = await this.assetRepository.filterNewExternalAssetPaths(library.id, pathBatch); + + if (paths.length > 0) { + importCount += paths.length; + + await this.jobRepository.queue({ + name: JobName.LIBRARY_SYNC_FILES, + data: { + libraryId: library.id, + paths, + progressCounter: crawlCount, + }, + }); + } + + this.logger.log( + `Crawled ${crawlCount} file(s) so far: ${paths.length} of current batch of ${pathBatch.length} will be imported to library ${library.id}...`, + ); } - if (count > 0) { - this.logger.debug(`Finished queueing scan of ${count} assets on disk for library ${library.id}`); - } else if (validImportPaths.length > 0) { - this.logger.debug(`No non-excluded assets found in any import path for library ${library.id}`); - } + this.logger.log( + `Finished disk crawl, ${crawlCount} file(s) found on disk and queued ${importCount} file(s) for import into ${library.id}`, + ); await this.libraryRepository.update(job.id, { refreshedAt: new Date() }); return JobStatus.SUCCESS; } + @OnJob({ name: JobName.LIBRARY_ASSET_REMOVAL, queue: QueueName.LIBRARY }) + async handleAssetRemoval(job: JobOf): Promise { + // This is only for handling file unlink events via the file watcher + this.logger.verbose(`Deleting asset(s) ${job.paths} from library ${job.libraryId}`); + for (const assetPath of job.paths) { + const asset = await this.assetRepository.getByLibraryIdAndOriginalPath(job.libraryId, assetPath); + if (asset) { + await this.assetRepository.remove(asset); + } + } + + return JobStatus.SUCCESS; + } + @OnJob({ name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, queue: QueueName.LIBRARY }) async handleQueueSyncAssets(job: JobOf): Promise { const library = await this.libraryRepository.get(job.id); @@ -583,29 +677,74 @@ export class LibraryService extends BaseService { return JobStatus.SKIPPED; } - this.logger.log(`Scanning library ${library.id} for removed assets`); + const assetCount = await this.assetRepository.getLibraryAssetCount(job.id); + if (!assetCount) { + this.logger.log(`Library ${library.id} is empty, no need to check assets`); + return JobStatus.SUCCESS; + } - const onlineAssets = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) => - this.assetRepository.getAll(pagination, { libraryId: job.id, withDeleted: true }), + this.logger.log( + `Checking ${assetCount} asset(s) against import paths and exclusion patterns in library ${library.id}...`, ); - let assetCount = 0; - for await (const assets of onlineAssets) { - assetCount += assets.length; - this.logger.debug(`Discovered ${assetCount} asset(s) in library ${library.id}...`); - await this.jobRepository.queueAll( - assets.map((asset) => ({ - name: JobName.LIBRARY_SYNC_ASSET, - data: { id: asset.id, importPaths: library.importPaths, exclusionPatterns: library.exclusionPatterns }, - })), - ); - this.logger.debug(`Queued check of ${assets.length} asset(s) in library ${library.id}...`); + const offlineResult = await this.assetRepository.detectOfflineExternalAssets( + library.id, + library.importPaths, + library.exclusionPatterns, + ); + + const affectedAssetCount = Number(offlineResult.numUpdatedRows); + + this.logger.log( + `${affectedAssetCount} asset(s) out of ${assetCount} were offlined due to import paths and/or exclusion pattern(s) in library ${library.id}`, + ); + + if (affectedAssetCount === assetCount) { + return JobStatus.SUCCESS; } - if (assetCount) { - this.logger.log(`Finished queueing check of ${assetCount} assets for library ${library.id}`); + let chunk: string[] = []; + let count = 0; + + const queueChunk = async () => { + if (chunk.length > 0) { + count += chunk.length; + + await this.jobRepository.queue({ + name: JobName.LIBRARY_SYNC_ASSETS, + data: { + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: chunk.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; } diff --git a/server/src/services/map.service.spec.ts b/server/src/services/map.service.spec.ts index 30505f7f5b..e86ad92976 100644 --- a/server/src/services/map.service.spec.ts +++ b/server/src/services/map.service.spec.ts @@ -1,23 +1,16 @@ -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { MapService } from 'src/services/map.service'; -import { IMapRepository } from 'src/types'; import { albumStub } from 'test/fixtures/album.stub'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { partnerStub } from 'test/fixtures/partner.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(MapService.name, () => { let sut: MapService; - - let albumMock: Mocked; - let mapMock: Mocked; - let partnerMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, albumMock, mapMock, partnerMock } = newTestService(MapService)); + ({ sut, mocks } = newTestService(MapService)); }); describe('getMapMarkers', () => { @@ -31,8 +24,8 @@ describe(MapService.name, () => { state: asset.exifInfo!.state, country: asset.exifInfo!.country, }; - partnerMock.getAll.mockResolvedValue([]); - mapMock.getMapMarkers.mockResolvedValue([marker]); + mocks.partner.getAll.mockResolvedValue([]); + mocks.map.getMapMarkers.mockResolvedValue([marker]); const markers = await sut.getMapMarkers(authStub.user1, {}); @@ -50,12 +43,12 @@ describe(MapService.name, () => { state: asset.exifInfo!.state, country: asset.exifInfo!.country, }; - partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1]); - mapMock.getMapMarkers.mockResolvedValue([marker]); + mocks.partner.getAll.mockResolvedValue([partnerStub.adminToUser1]); + mocks.map.getMapMarkers.mockResolvedValue([marker]); const markers = await sut.getMapMarkers(authStub.user1, { withPartners: true }); - expect(mapMock.getMapMarkers).toHaveBeenCalledWith( + expect(mocks.map.getMapMarkers).toHaveBeenCalledWith( [authStub.user1.user.id, partnerStub.adminToUser1.sharedById], expect.arrayContaining([]), { withPartners: true }, @@ -74,10 +67,10 @@ describe(MapService.name, () => { state: asset.exifInfo!.state, country: asset.exifInfo!.country, }; - partnerMock.getAll.mockResolvedValue([]); - mapMock.getMapMarkers.mockResolvedValue([marker]); - albumMock.getOwned.mockResolvedValue([albumStub.empty]); - albumMock.getShared.mockResolvedValue([albumStub.sharedWithUser]); + mocks.partner.getAll.mockResolvedValue([]); + mocks.map.getMapMarkers.mockResolvedValue([marker]); + mocks.album.getOwned.mockResolvedValue([albumStub.empty]); + mocks.album.getShared.mockResolvedValue([albumStub.sharedWithUser]); const markers = await sut.getMapMarkers(authStub.user1, { withSharedAlbums: true }); @@ -88,13 +81,13 @@ describe(MapService.name, () => { describe('reverseGeocode', () => { it('should reverse geocode a location', async () => { - mapMock.reverseGeocode.mockResolvedValue({ city: 'foo', state: 'bar', country: 'baz' }); + mocks.map.reverseGeocode.mockResolvedValue({ city: 'foo', state: 'bar', country: 'baz' }); await expect(sut.reverseGeocode({ lat: 42, lon: 69 })).resolves.toEqual([ { city: 'foo', state: 'bar', country: 'baz' }, ]); - expect(mapMock.reverseGeocode).toHaveBeenCalledWith({ latitude: 42, longitude: 69 }); + expect(mocks.map.reverseGeocode).toHaveBeenCalledWith({ latitude: 42, longitude: 69 }); }); }); }); diff --git a/server/src/services/map.service.ts b/server/src/services/map.service.ts index 860a782e79..94eca77a60 100644 --- a/server/src/services/map.service.ts +++ b/server/src/services/map.service.ts @@ -1,8 +1,10 @@ +import { Injectable } from '@nestjs/common'; import { AuthDto } from 'src/dtos/auth.dto'; import { MapMarkerDto, MapMarkerResponseDto, MapReverseGeocodeDto } from 'src/dtos/map.dto'; import { BaseService } from 'src/services/base.service'; import { getMyPartnerIds } from 'src/utils/asset.util'; +@Injectable() export class MapService extends BaseService { async getMapMarkers(auth: AuthDto, options: MapMarkerDto): Promise { const userIds = [auth.user.id]; diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 9ce6c8edb9..d98cff866f 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -9,40 +9,27 @@ import { AudioCodec, Colorspace, ImageFormat, + JobName, + JobStatus, TranscodeHWAccel, TranscodePolicy, VideoCodec, } from 'src/enum'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { IJobRepository, JobCounts, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { IMoveRepository } from 'src/interfaces/move.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { WithoutProperty } from 'src/repositories/asset.repository'; import { MediaService } from 'src/services/media.service'; -import { ILoggingRepository, IMediaRepository, RawImageInfo } from 'src/types'; +import { JobCounts, RawImageInfo } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { probeStub } from 'test/fixtures/media.stub'; import { personStub } from 'test/fixtures/person.stub'; -import { makeStream, newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { makeStream, newTestService, ServiceMocks } from 'test/utils'; describe(MediaService.name, () => { let sut: MediaService; - - let assetMock: Mocked; - let jobMock: Mocked; - let loggerMock: Mocked; - let mediaMock: Mocked; - let moveMock: Mocked; - let personMock: Mocked; - let storageMock: Mocked; - let systemMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, assetMock, jobMock, loggerMock, mediaMock, moveMock, personMock, storageMock, systemMock } = - newTestService(MediaService)); + ({ sut, mocks } = newTestService(MediaService)); }); it('should be defined', () => { @@ -51,27 +38,27 @@ describe(MediaService.name, () => { describe('handleQueueGenerateThumbnails', () => { it('should queue all assets', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); - personMock.getAll.mockReturnValue(makeStream([personStub.newThumbnail])); - personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); + mocks.person.getAll.mockReturnValue(makeStream([personStub.newThumbnail])); + mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]); await sut.handleQueueGenerateThumbnails({ force: true }); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(assetMock.getWithout).not.toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.asset.getWithout).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); - expect(personMock.getAll).toHaveBeenCalledWith(undefined); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.getAll).toHaveBeenCalledWith(undefined); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personStub.newThumbnail.id }, @@ -80,20 +67,20 @@ describe(MediaService.name, () => { }); it('should queue trashed assets when force is true', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.trashed], hasNextPage: false, }); - personMock.getAll.mockReturnValue(makeStream()); + mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: true }); - expect(assetMock.getAll).toHaveBeenCalledWith( + expect(mocks.asset.getAll).toHaveBeenCalledWith( { skip: 0, take: 1000 }, expect.objectContaining({ withDeleted: true }), ); - expect(assetMock.getWithout).not.toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getWithout).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.trashed.id }, @@ -102,20 +89,20 @@ describe(MediaService.name, () => { }); it('should queue archived assets when force is true', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.archived], hasNextPage: false, }); - personMock.getAll.mockReturnValue(makeStream()); + mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: true }); - expect(assetMock.getAll).toHaveBeenCalledWith( + expect(mocks.asset.getAll).toHaveBeenCalledWith( { skip: 0, take: 1000 }, expect.objectContaining({ withArchived: true }), ); - expect(assetMock.getWithout).not.toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getWithout).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.archived.id }, @@ -124,22 +111,22 @@ describe(MediaService.name, () => { }); it('should queue all people with missing thumbnail path', async () => { - assetMock.getWithout.mockResolvedValue({ + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); - personMock.getAll.mockReturnValue(makeStream([personStub.noThumbnail, personStub.noThumbnail])); - personMock.getRandomFace.mockResolvedValueOnce(faceStub.face1); + mocks.person.getAll.mockReturnValue(makeStream([personStub.noThumbnail, personStub.noThumbnail])); + mocks.person.getRandomFace.mockResolvedValueOnce(faceStub.face1); await sut.handleQueueGenerateThumbnails({ force: false }); - expect(assetMock.getAll).not.toHaveBeenCalled(); - expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); + expect(mocks.asset.getAll).not.toHaveBeenCalled(); + expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); - expect(personMock.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); - expect(personMock.getRandomFace).toHaveBeenCalled(); - expect(personMock.update).toHaveBeenCalledTimes(1); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + expect(mocks.person.getRandomFace).toHaveBeenCalled(); + expect(mocks.person.update).toHaveBeenCalledTimes(1); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_PERSON_THUMBNAIL, data: { @@ -150,79 +137,79 @@ describe(MediaService.name, () => { }); it('should queue all assets with missing resize path', async () => { - assetMock.getWithout.mockResolvedValue({ + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.noResizePath], hasNextPage: false, }); - personMock.getAll.mockReturnValue(makeStream()); + mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); - expect(assetMock.getAll).not.toHaveBeenCalled(); - expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).not.toHaveBeenCalled(); + expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); - expect(personMock.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); it('should queue all assets with missing webp path', async () => { - assetMock.getWithout.mockResolvedValue({ + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.noWebpPath], hasNextPage: false, }); - personMock.getAll.mockReturnValue(makeStream()); + mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); - expect(assetMock.getAll).not.toHaveBeenCalled(); - expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).not.toHaveBeenCalled(); + expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); - expect(personMock.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); it('should queue all assets with missing thumbhash', async () => { - assetMock.getWithout.mockResolvedValue({ + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.noThumbhash], hasNextPage: false, }); - personMock.getAll.mockReturnValue(makeStream()); + mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); - expect(assetMock.getAll).not.toHaveBeenCalled(); - expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).not.toHaveBeenCalled(); + expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); - expect(personMock.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); }); describe('handleQueueMigration', () => { it('should remove empty directories and queue jobs', async () => { - assetMock.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] }); - jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts); - personMock.getAll.mockReturnValue(makeStream([personStub.withName])); + mocks.asset.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] }); + mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts); + mocks.person.getAll.mockReturnValue(makeStream([personStub.withName])); await expect(sut.handleQueueMigration()).resolves.toBe(JobStatus.SUCCESS); - expect(storageMock.removeEmptyDirs).toHaveBeenCalledTimes(2); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.storage.removeEmptyDirs).toHaveBeenCalledTimes(2); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.MIGRATE_ASSET, data: { id: assetStub.image.id } }, ]); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.MIGRATE_PERSON, data: { id: personStub.withName.id } }, ]); }); @@ -232,12 +219,12 @@ describe(MediaService.name, () => { it('should fail if asset does not exist', async () => { await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); - expect(moveMock.getByEntity).not.toHaveBeenCalled(); + expect(mocks.move.getByEntity).not.toHaveBeenCalled(); }); it('should move asset files', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - moveMock.create.mockResolvedValue({ + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.move.create.mockResolvedValue({ entityId: assetStub.image.id, id: 'move-id', newPath: '/new/path', @@ -246,7 +233,7 @@ describe(MediaService.name, () => { }); await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); - expect(moveMock.create).toHaveBeenCalledTimes(2); + expect(mocks.move.create).toHaveBeenCalledTimes(2); }); }); @@ -257,72 +244,72 @@ describe(MediaService.name, () => { beforeEach(() => { rawBuffer = Buffer.from('image data'); rawInfo = { width: 100, height: 100, channels: 3 }; - mediaMock.decodeImage.mockResolvedValue({ data: rawBuffer, info: rawInfo as OutputInfo }); + mocks.media.decodeImage.mockResolvedValue({ data: rawBuffer, info: rawInfo as OutputInfo }); }); it('should skip thumbnail generation if asset not found', async () => { await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalledWith(); }); it('should skip thumbnail generation if asset type is unknown', async () => { - assetMock.getById.mockResolvedValue({ ...assetStub.image, type: 'foo' } as never as AssetEntity); + mocks.asset.getById.mockResolvedValue({ ...assetStub.image, type: 'foo' } as never as AssetEntity); await expect(sut.handleGenerateThumbnails({ id: assetStub.image.id })).resolves.toBe(JobStatus.SKIPPED); - expect(mediaMock.probe).not.toHaveBeenCalled(); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); + expect(mocks.media.probe).not.toHaveBeenCalled(); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalledWith(); }); it('should skip video thumbnail generation if no video stream', async () => { - mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams); - assetMock.getById.mockResolvedValue(assetStub.video); + mocks.media.probe.mockResolvedValue(probeStub.noVideoStreams); + mocks.asset.getById.mockResolvedValue(assetStub.video); await expect(sut.handleGenerateThumbnails({ id: assetStub.video.id })).rejects.toThrowError(); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalledWith(); }); it('should skip invisible assets', async () => { - assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); expect(await sut.handleGenerateThumbnails({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalledWith(); }); it('should delete previous preview if different path', async () => { - systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); + mocks.asset.getById.mockResolvedValue(assetStub.image); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg'); + expect(mocks.storage.unlink).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg'); }); it('should generate P3 thumbnails for a wide gamut image', async () => { - assetMock.getById.mockResolvedValue({ + mocks.asset.getById.mockResolvedValue({ ...assetStub.image, exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity, }); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); - mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); + mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); - expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, }); - expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { colorspace: Colorspace.P3, @@ -334,7 +321,7 @@ describe(MediaService.name, () => { }, 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', ); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { colorspace: Colorspace.P3, @@ -347,14 +334,14 @@ describe(MediaService.name, () => { 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', ); - expect(mediaMock.generateThumbhash).toHaveBeenCalledOnce(); - expect(mediaMock.generateThumbhash).toHaveBeenCalledWith(rawBuffer, { + expect(mocks.media.generateThumbhash).toHaveBeenCalledOnce(); + expect(mocks.media.generateThumbhash).toHaveBeenCalledWith(rawBuffer, { colorspace: Colorspace.P3, processInvalidImages: false, raw: rawInfo, }); - expect(assetMock.upsertFiles).toHaveBeenCalledWith([ + expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ { assetId: 'asset-id', type: AssetFileType.PREVIEW, @@ -366,16 +353,16 @@ describe(MediaService.name, () => { path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', }, ]); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); }); it('should generate a thumbnail for a video', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - assetMock.getById.mockResolvedValue(assetStub.video); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.asset.getById.mockResolvedValue(assetStub.video); await sut.handleGenerateThumbnails({ id: assetStub.video.id }); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', expect.objectContaining({ @@ -390,7 +377,7 @@ describe(MediaService.name, () => { twoPass: false, }), ); - expect(assetMock.upsertFiles).toHaveBeenCalledWith([ + expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ { assetId: 'asset-id', type: AssetFileType.PREVIEW, @@ -405,12 +392,12 @@ describe(MediaService.name, () => { }); it('should tonemap thumbnail for hdr video', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - assetMock.getById.mockResolvedValue(assetStub.video); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.asset.getById.mockResolvedValue(assetStub.video); await sut.handleGenerateThumbnails({ id: assetStub.video.id }); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', expect.objectContaining({ @@ -425,7 +412,7 @@ describe(MediaService.name, () => { twoPass: false, }), ); - expect(assetMock.upsertFiles).toHaveBeenCalledWith([ + expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ { assetId: 'asset-id', type: AssetFileType.PREVIEW, @@ -440,14 +427,14 @@ describe(MediaService.name, () => { }); it('should always generate video thumbnail in one pass', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { twoPass: true, maxBitrate: '5000k' }, }); - assetMock.getById.mockResolvedValue(assetStub.video); + mocks.asset.getById.mockResolvedValue(assetStub.video); await sut.handleGenerateThumbnails({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', expect.objectContaining({ @@ -464,11 +451,11 @@ describe(MediaService.name, () => { ); }); it('should not skip intra frames for MTS file', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamMTS); - assetMock.getById.mockResolvedValue(assetStub.video); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamMTS); + mocks.asset.getById.mockResolvedValue(assetStub.video); await sut.handleGenerateThumbnails({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', expect.objectContaining({ @@ -481,12 +468,12 @@ describe(MediaService.name, () => { }); it('should use scaling divisible by 2 even when using quick sync', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); - assetMock.getById.mockResolvedValue(assetStub.video); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); + mocks.asset.getById.mockResolvedValue(assetStub.video); await sut.handleGenerateThumbnails({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', expect.objectContaining({ @@ -498,25 +485,25 @@ describe(MediaService.name, () => { }); it.each(Object.values(ImageFormat))('should generate an image preview in %s format', async (format) => { - systemMock.get.mockResolvedValue({ image: { preview: { format } } }); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.systemMetadata.get.mockResolvedValue({ image: { preview: { format } } }); + mocks.asset.getById.mockResolvedValue(assetStub.image); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); - mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); + mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`; const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.webp`; await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); - expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { colorspace: Colorspace.SRGB, processInvalidImages: false, size: 1440, }); - expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { colorspace: Colorspace.SRGB, @@ -528,7 +515,7 @@ describe(MediaService.name, () => { }, previewPath, ); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { colorspace: Colorspace.SRGB, @@ -543,25 +530,25 @@ describe(MediaService.name, () => { }); it.each(Object.values(ImageFormat))('should generate an image thumbnail in %s format', async (format) => { - systemMock.get.mockResolvedValue({ image: { thumbnail: { format } } }); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format } } }); + mocks.asset.getById.mockResolvedValue(assetStub.image); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); - mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); + mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.jpeg`; const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`; await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); - expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { colorspace: Colorspace.SRGB, processInvalidImages: false, size: 1440, }); - expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { colorspace: Colorspace.SRGB, @@ -573,7 +560,7 @@ describe(MediaService.name, () => { }, previewPath, ); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { colorspace: Colorspace.SRGB, @@ -588,132 +575,132 @@ describe(MediaService.name, () => { }); it('should delete previous thumbnail if different path', async () => { - systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); + mocks.asset.getById.mockResolvedValue(assetStub.image); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/webp/path.ext'); + expect(mocks.storage.unlink).toHaveBeenCalledWith('/uploads/user-id/webp/path.ext'); }); it('should extract embedded image if enabled and available', async () => { - mediaMock.extract.mockResolvedValue(true); - mediaMock.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); - systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); - assetMock.getById.mockResolvedValue(assetStub.imageDng); + mocks.media.extract.mockResolvedValue(true); + mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); + mocks.asset.getById.mockResolvedValue(assetStub.imageDng); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); - expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); - expect(mediaMock.decodeImage).toHaveBeenCalledWith(extractedPath, { + const extractedPath = mocks.media.extract.mock.calls.at(-1)?.[1].toString(); + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedPath, { colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, }); expect(extractedPath?.endsWith('.tmp')).toBe(true); - expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); + expect(mocks.storage.unlink).toHaveBeenCalledWith(extractedPath); }); it('should resize original image if embedded image is too small', async () => { - mediaMock.extract.mockResolvedValue(true); - mediaMock.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); - systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); - assetMock.getById.mockResolvedValue(assetStub.imageDng); + mocks.media.extract.mockResolvedValue(true); + mocks.media.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); + mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); + mocks.asset.getById.mockResolvedValue(assetStub.imageDng); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); - expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, }); - const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); + const extractedPath = mocks.media.extract.mock.calls.at(-1)?.[1].toString(); expect(extractedPath?.endsWith('.tmp')).toBe(true); - expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); + expect(mocks.storage.unlink).toHaveBeenCalledWith(extractedPath); }); it('should resize original image if embedded image not found', async () => { - systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); - assetMock.getById.mockResolvedValue(assetStub.imageDng); + mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); + mocks.asset.getById.mockResolvedValue(assetStub.imageDng); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); - expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, }); - expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); + expect(mocks.media.getImageDimensions).not.toHaveBeenCalled(); }); it('should resize original image if embedded image extraction is not enabled', async () => { - systemMock.get.mockResolvedValue({ image: { extractEmbedded: false } }); - assetMock.getById.mockResolvedValue(assetStub.imageDng); + mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: false } }); + mocks.asset.getById.mockResolvedValue(assetStub.imageDng); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mediaMock.extract).not.toHaveBeenCalled(); - expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); - expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + expect(mocks.media.extract).not.toHaveBeenCalled(); + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, }); - expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); + expect(mocks.media.getImageDimensions).not.toHaveBeenCalled(); }); it('should process invalid images if enabled', async () => { vi.stubEnv('IMMICH_PROCESS_INVALID_IMAGES', 'true'); - assetMock.getById.mockResolvedValue(assetStub.imageDng); + mocks.asset.getById.mockResolvedValue(assetStub.imageDng); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); - expect(mediaMock.decodeImage).toHaveBeenCalledWith( + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith( assetStub.imageDng.originalPath, expect.objectContaining({ processInvalidImages: true }), ); - expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, expect.objectContaining({ processInvalidImages: true }), 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', ); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, expect.objectContaining({ processInvalidImages: true }), 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', ); - expect(mediaMock.generateThumbhash).toHaveBeenCalledOnce(); - expect(mediaMock.generateThumbhash).toHaveBeenCalledWith( + expect(mocks.media.generateThumbhash).toHaveBeenCalledOnce(); + expect(mocks.media.generateThumbhash).toHaveBeenCalledWith( rawBuffer, expect.objectContaining({ processInvalidImages: true }), ); - expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); + expect(mocks.media.getImageDimensions).not.toHaveBeenCalled(); vi.unstubAllEnvs(); }); }); describe('handleQueueVideoConversion', () => { it('should queue all video assets', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.video], hasNextPage: false, }); - personMock.getAll.mockReturnValue(makeStream()); + mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueVideoConversion({ force: true }); - expect(assetMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { type: AssetType.VIDEO }); - expect(assetMock.getWithout).not.toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { type: AssetType.VIDEO }); + expect(mocks.asset.getWithout).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.VIDEO_CONVERSION, data: { id: assetStub.video.id }, @@ -722,16 +709,16 @@ describe(MediaService.name, () => { }); it('should queue all video assets without encoded videos', async () => { - assetMock.getWithout.mockResolvedValue({ + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.video], hasNextPage: false, }); await sut.handleQueueVideoConversion({}); - expect(assetMock.getAll).not.toHaveBeenCalled(); - expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.ENCODED_VIDEO); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).not.toHaveBeenCalled(); + expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.ENCODED_VIDEO); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.VIDEO_CONVERSION, data: { id: assetStub.video.id }, @@ -742,35 +729,35 @@ describe(MediaService.name, () => { describe('handleVideoConversion', () => { beforeEach(() => { - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); sut.videoInterfaces = { dri: ['renderD128'], mali: true }; }); it('should skip transcoding if asset not found', async () => { - assetMock.getByIds.mockResolvedValue([]); + mocks.asset.getByIds.mockResolvedValue([]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.probe).not.toHaveBeenCalled(); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.probe).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should skip transcoding if non-video asset', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); await sut.handleVideoConversion({ id: assetStub.image.id }); - expect(mediaMock.probe).not.toHaveBeenCalled(); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.probe).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should transcode the longest stream', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.video]); - loggerMock.isLevelEnabled.mockReturnValue(false); - mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); + mocks.logger.isLevelEnabled.mockReturnValue(false); + mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext', { countFrames: false }); - expect(systemMock.get).toHaveBeenCalled(); - expect(storageMock.mkdirSync).toHaveBeenCalled(); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.probe).toHaveBeenCalledWith('/original/path.ext', { countFrames: false }); + expect(mocks.systemMetadata.get).toHaveBeenCalled(); + expect(mocks.storage.mkdirSync).toHaveBeenCalled(); + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -782,46 +769,46 @@ describe(MediaService.name, () => { }); it('should skip a video without any streams', async () => { - mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.noVideoStreams); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should skip a video without any height', async () => { - mediaMock.probe.mockResolvedValue(probeStub.noHeight); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.noHeight); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should throw an error if an unknown transcode policy is configured', async () => { - mediaMock.probe.mockResolvedValue(probeStub.noAudioStreams); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: 'foo' } } as never as SystemConfig); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.noAudioStreams); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: 'foo' } } as never as SystemConfig); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should throw an error if transcoding fails and hw acceleration is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, accel: TranscodeHWAccel.DISABLED }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - mediaMock.transcode.mockRejectedValue(new Error('Error transcoding video')); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.transcode.mockRejectedValue(new Error('Error transcoding video')); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); - expect(mediaMock.transcode).toHaveBeenCalledTimes(1); + expect(mocks.media.transcode).toHaveBeenCalledTimes(1); }); it('should transcode when set to all', async () => { - mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -833,10 +820,10 @@ describe(MediaService.name, () => { }); it('should transcode when optimal and too big', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -848,10 +835,10 @@ describe(MediaService.name, () => { }); it('should transcode when policy bitrate and bitrate higher than max bitrate', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream40Mbps); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.BITRATE, maxBitrate: '30M' } }); + mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.BITRATE, maxBitrate: '30M' } }); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -863,10 +850,10 @@ describe(MediaService.name, () => { }); it('should transcode when max bitrate is not a number', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream40Mbps); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.BITRATE, maxBitrate: 'foo' } }); + mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.BITRATE, maxBitrate: 'foo' } }); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -878,10 +865,12 @@ describe(MediaService.name, () => { }); it('should not scale resolution if no target resolution', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' } }); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' }, + }); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -893,11 +882,11 @@ describe(MediaService.name, () => { }); it('should scale horizontally when video is horizontal', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -909,11 +898,11 @@ describe(MediaService.name, () => { }); it('should scale vertically when video is vertical', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -925,11 +914,13 @@ describe(MediaService.name, () => { }); it('should always scale video if height is uneven', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamOddHeight); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamOddHeight); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' }, + }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -941,11 +932,13 @@ describe(MediaService.name, () => { }); it('should always scale video if width is uneven', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamOddWidth); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamOddWidth); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' }, + }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -957,13 +950,13 @@ describe(MediaService.name, () => { }); it('should copy video stream when video matches target', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.HEVC, acceptedAudioCodecs: [AudioCodec.AAC] }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -975,17 +968,17 @@ describe(MediaService.name, () => { }); it('should not include hevc tag when target is hevc and video stream is copied from a different codec', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamH264); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStreamH264); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.HEVC, acceptedVideoCodecs: [VideoCodec.H264, VideoCodec.HEVC], acceptedAudioCodecs: [AudioCodec.AAC], }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -997,17 +990,17 @@ describe(MediaService.name, () => { }); it('should include hevc tag when target is hevc and copying hevc video stream', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.HEVC, acceptedVideoCodecs: [VideoCodec.H264, VideoCodec.HEVC], acceptedAudioCodecs: [AudioCodec.AAC], }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1019,11 +1012,11 @@ describe(MediaService.name, () => { }); it('should copy audio stream when audio matches target', async () => { - mediaMock.probe.mockResolvedValue(probeStub.audioStreamAac); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.audioStreamAac); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1035,10 +1028,10 @@ describe(MediaService.name, () => { }); it('should remux when input is not an accepted container', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamAvi); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamAvi); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1050,58 +1043,58 @@ describe(MediaService.name, () => { }); it('should throw an exception if transcode value is invalid', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: 'invalid' as any } }); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: 'invalid' as any } }); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should not transcode if transcoding is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should not remux when input is not an accepted container and transcoding is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should not transcode if target codec is invalid', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: 'invalid' as any } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: 'invalid' as any } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should delete existing transcode if current policy does not require transcoding', async () => { const asset = assetStub.hasEncodedVideo; - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); - assetMock.getByIds.mockResolvedValue([asset]); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); + mocks.asset.getByIds.mockResolvedValue([asset]); await sut.handleVideoConversion({ id: asset.id }); - expect(mediaMock.transcode).not.toHaveBeenCalled(); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.media.transcode).not.toHaveBeenCalled(); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.DELETE_FILES, data: { files: [asset.encodedVideoPath] }, }); }); it('should set max bitrate if above 0', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { maxBitrate: '4500k' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { maxBitrate: '4500k' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1113,11 +1106,11 @@ describe(MediaService.name, () => { }); it('should default max bitrate to kbps if no unit is provided', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { maxBitrate: '4500' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { maxBitrate: '4500' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1129,11 +1122,11 @@ describe(MediaService.name, () => { }); it('should transcode in two passes for h264/h265 when enabled and max bitrate is above 0', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { twoPass: true, maxBitrate: '4500k' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { twoPass: true, maxBitrate: '4500k' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1145,11 +1138,11 @@ describe(MediaService.name, () => { }); it('should fallback to one pass for h264/h265 if two-pass is enabled but no max bitrate is set', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { twoPass: true } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { twoPass: true } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1161,17 +1154,17 @@ describe(MediaService.name, () => { }); it('should transcode by bitrate in two passes for vp9 when two pass mode and max bitrate are enabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { maxBitrate: '4500k', twoPass: true, targetVideoCodec: VideoCodec.VP9, }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1183,17 +1176,17 @@ describe(MediaService.name, () => { }); it('should transcode by crf in two passes for vp9 when two pass mode is enabled and max bitrate is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { maxBitrate: '0', twoPass: true, targetVideoCodec: VideoCodec.VP9, }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1205,11 +1198,11 @@ describe(MediaService.name, () => { }); it('should configure preset for vp9', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.VP9, preset: 'slow' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.VP9, preset: 'slow' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1221,11 +1214,11 @@ describe(MediaService.name, () => { }); it('should not configure preset for vp9 if invalid', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { preset: 'invalid', targetVideoCodec: VideoCodec.VP9 } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { preset: 'invalid', targetVideoCodec: VideoCodec.VP9 } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1237,11 +1230,11 @@ describe(MediaService.name, () => { }); it('should configure threads if above 0', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.VP9, threads: 2 } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.VP9, threads: 2 } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1253,11 +1246,11 @@ describe(MediaService.name, () => { }); it('should disable thread pooling for h264 if thread limit is 1', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { threads: 1 } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 1 } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1269,11 +1262,11 @@ describe(MediaService.name, () => { }); it('should omit thread flags for h264 if thread limit is at or below 0', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { threads: 0 } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 0 } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1285,11 +1278,11 @@ describe(MediaService.name, () => { }); it('should disable thread pooling for hevc if thread limit is 1', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - systemMock.get.mockResolvedValue({ ffmpeg: { threads: 1, targetVideoCodec: VideoCodec.HEVC } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 1, targetVideoCodec: VideoCodec.HEVC } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1301,11 +1294,11 @@ describe(MediaService.name, () => { }); it('should omit thread flags for hevc if thread limit is at or below 0', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - systemMock.get.mockResolvedValue({ ffmpeg: { threads: 0, targetVideoCodec: VideoCodec.HEVC } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 0, targetVideoCodec: VideoCodec.HEVC } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1317,11 +1310,11 @@ describe(MediaService.name, () => { }); it('should use av1 if specified', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1 } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1 } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1343,11 +1336,11 @@ describe(MediaService.name, () => { }); it('should map `veryslow` preset to 4 for av1', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, preset: 'veryslow' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, preset: 'veryslow' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1359,11 +1352,11 @@ describe(MediaService.name, () => { }); it('should set max bitrate for av1 if specified', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, maxBitrate: '2M' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, maxBitrate: '2M' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1375,11 +1368,11 @@ describe(MediaService.name, () => { }); it('should set threads for av1 if specified', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, threads: 4 } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, threads: 4 } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1391,11 +1384,13 @@ describe(MediaService.name, () => { }); it('should set both bitrate and threads for av1 if specified', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, threads: 4, maxBitrate: '2M' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { targetVideoCodec: VideoCodec.AV1, threads: 4, maxBitrate: '2M' }, + }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1407,41 +1402,43 @@ describe(MediaService.name, () => { }); it('should skip transcoding for audioless videos with optimal policy if video codec is correct', async () => { - mediaMock.probe.mockResolvedValue(probeStub.noAudioStreams); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.noAudioStreams); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.HEVC, transcode: TranscodePolicy.OPTIMAL, targetResolution: '1080p', }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should fail if hwaccel is enabled for an unsupported codec', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, targetVideoCodec: VideoCodec.VP9 } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHWAccel.NVENC, targetVideoCodec: VideoCodec.VP9 }, + }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should fail if hwaccel option is invalid', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: 'invalid' as any } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: 'invalid' as any } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should set options for nvenc', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1469,17 +1466,17 @@ describe(MediaService.name, () => { }); it('should set two pass options for nvenc when enabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, maxBitrate: '10000k', twoPass: true, }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1491,11 +1488,11 @@ describe(MediaService.name, () => { }); it('should set vbr options for nvenc when max bitrate is enabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, maxBitrate: '10000k' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, maxBitrate: '10000k' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1507,11 +1504,11 @@ describe(MediaService.name, () => { }); it('should set cq options for nvenc when max bitrate is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, maxBitrate: '10000k' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, maxBitrate: '10000k' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1523,11 +1520,11 @@ describe(MediaService.name, () => { }); it('should omit preset for nvenc if invalid', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, preset: 'invalid' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, preset: 'invalid' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1539,11 +1536,11 @@ describe(MediaService.name, () => { }); it('should ignore two pass for nvenc if max bitrate is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1555,13 +1552,13 @@ describe(MediaService.name, () => { }); it('should use hardware decoding for nvenc if enabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, accelDecode: true }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1578,13 +1575,13 @@ describe(MediaService.name, () => { }); it('should use hardware tone-mapping for nvenc if hardware decoding is enabled and should tone map', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, accelDecode: true }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1600,13 +1597,13 @@ describe(MediaService.name, () => { }); it('should set format to nv12 for nvenc if input is not yuv420p', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStream10Bit); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, accelDecode: true }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1618,11 +1615,11 @@ describe(MediaService.name, () => { }); it('should set options for qsv', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, maxBitrate: '10000k' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, maxBitrate: '10000k' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1653,17 +1650,17 @@ describe(MediaService.name, () => { }); it('should set options for qsv with custom dri node', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, maxBitrate: '10000k', preferredHwDevice: '/dev/dri/renderD128', }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1678,11 +1675,11 @@ describe(MediaService.name, () => { }); it('should omit preset for qsv if invalid', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, preset: 'invalid' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, preset: 'invalid' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1697,11 +1694,13 @@ describe(MediaService.name, () => { }); it('should set low power mode for qsv if target video codec is vp9', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, targetVideoCodec: VideoCodec.VP9 } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHWAccel.QSV, targetVideoCodec: VideoCodec.VP9 }, + }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1717,22 +1716,22 @@ describe(MediaService.name, () => { it('should fail for qsv if no hw devices', async () => { sut.videoInterfaces = { dri: [], mali: false }; - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should prefer higher index renderD* device for qsv', async () => { sut.videoInterfaces = { dri: ['card1', 'renderD129', 'card0', 'renderD128'], mali: false }; - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1747,15 +1746,15 @@ describe(MediaService.name, () => { }); it('should use hardware decoding for qsv if enabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1776,15 +1775,15 @@ describe(MediaService.name, () => { }); it('should use hardware tone-mapping for qsv if hardware decoding is enabled and should tone map', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1806,14 +1805,14 @@ describe(MediaService.name, () => { it('should use preferred device for qsv when hardware decoding', async () => { sut.videoInterfaces = { dri: ['renderD128', 'renderD129', 'renderD130'], mali: false }; - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true, preferredHwDevice: 'renderD129' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1825,15 +1824,15 @@ describe(MediaService.name, () => { }); it('should set format to nv12 for qsv if input is not yuv420p', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStream10Bit); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1850,11 +1849,11 @@ describe(MediaService.name, () => { }); it('should set options for vaapi', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1881,11 +1880,11 @@ describe(MediaService.name, () => { }); it('should set vbr options for vaapi when max bitrate is enabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, maxBitrate: '10000k' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, maxBitrate: '10000k' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1906,11 +1905,11 @@ describe(MediaService.name, () => { }); it('should set cq options for vaapi when max bitrate is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1931,11 +1930,11 @@ describe(MediaService.name, () => { }); it('should omit preset for vaapi if invalid', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, preset: 'invalid' } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, preset: 'invalid' } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1951,11 +1950,11 @@ describe(MediaService.name, () => { it('should prefer higher index renderD* device for vaapi', async () => { sut.videoInterfaces = { dri: ['card1', 'renderD129', 'card0', 'renderD128'], mali: false }; - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1971,13 +1970,13 @@ describe(MediaService.name, () => { it('should select specific gpu node if selected', async () => { sut.videoInterfaces = { dri: ['renderD129', 'card1', 'card0', 'renderD128'], mali: false }; - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, preferredHwDevice: '/dev/dri/renderD128' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -1992,15 +1991,15 @@ describe(MediaService.name, () => { }); it('should use hardware decoding for vaapi if enabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2020,15 +2019,15 @@ describe(MediaService.name, () => { }); it('should use hardware tone-mapping for vaapi if hardware decoding is enabled and should tone map', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2044,15 +2043,15 @@ describe(MediaService.name, () => { }); it('should set format to nv12 for vaapi if input is not yuv420p', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStream10Bit); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2065,14 +2064,14 @@ describe(MediaService.name, () => { it('should use preferred device for vaapi when hardware decoding', async () => { sut.videoInterfaces = { dri: ['renderD128', 'renderD129', 'renderD130'], mali: false }; - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true, preferredHwDevice: 'renderD129' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2084,13 +2083,13 @@ describe(MediaService.name, () => { }); it('should fallback to hw encoding and sw decoding if hw transcoding fails and hw decoding is enabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - mediaMock.transcode.mockRejectedValueOnce(new Error('error')); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.transcode.mockRejectedValueOnce(new Error('error')); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledTimes(2); - expect(mediaMock.transcode).toHaveBeenLastCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledTimes(2); + expect(mocks.media.transcode).toHaveBeenLastCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2105,14 +2104,14 @@ describe(MediaService.name, () => { }); it('should fallback to sw decoding if fallback to sw decoding + hw encoding fails', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - mediaMock.transcode.mockRejectedValueOnce(new Error('error')); - mediaMock.transcode.mockRejectedValueOnce(new Error('error')); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.transcode.mockRejectedValueOnce(new Error('error')); + mocks.media.transcode.mockRejectedValueOnce(new Error('error')); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledTimes(3); - expect(mediaMock.transcode).toHaveBeenLastCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledTimes(3); + expect(mocks.media.transcode).toHaveBeenLastCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2124,13 +2123,13 @@ describe(MediaService.name, () => { }); it('should fallback to sw transcoding if hw transcoding fails and hw decoding is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - mediaMock.transcode.mockRejectedValueOnce(new Error('error')); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.transcode.mockRejectedValueOnce(new Error('error')); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledTimes(2); - expect(mediaMock.transcode).toHaveBeenLastCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledTimes(2); + expect(mocks.media.transcode).toHaveBeenLastCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2143,19 +2142,19 @@ describe(MediaService.name, () => { it('should fail for vaapi if no hw devices', async () => { sut.videoInterfaces = { dri: [], mali: true }; - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); - expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should set options for rkmpp', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2185,8 +2184,8 @@ describe(MediaService.name, () => { }); it('should set vbr options for rkmpp when max bitrate is enabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, @@ -2194,9 +2193,9 @@ describe(MediaService.name, () => { targetVideoCodec: VideoCodec.HEVC, }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2208,13 +2207,13 @@ describe(MediaService.name, () => { }); it('should set cqp options for rkmpp when max bitrate is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2226,13 +2225,13 @@ describe(MediaService.name, () => { }); it('should set OpenCL tonemapping options for rkmpp when OpenCL is available', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2249,13 +2248,13 @@ describe(MediaService.name, () => { it('should set hardware decoding options for rkmpp when hardware decoding is enabled with no OpenCL on non-HDR file', async () => { sut.videoInterfaces = { dri: ['renderD128'], mali: false }; - mediaMock.probe.mockResolvedValue(probeStub.noAudioStreams); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.noAudioStreams); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2269,13 +2268,13 @@ describe(MediaService.name, () => { }); it('should use software decoding and tone-mapping if hardware decoding is disabled', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: false, crf: 30, maxBitrate: '0' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2292,13 +2291,13 @@ describe(MediaService.name, () => { it('should use software tone-mapping if opencl is not available', async () => { sut.videoInterfaces = { dri: ['renderD128'], mali: false }; - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2314,11 +2313,11 @@ describe(MediaService.name, () => { }); it('should tonemap when policy is required and video is hdr', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2334,11 +2333,11 @@ describe(MediaService.name, () => { }); it('should tonemap when policy is optimal and video is hdr', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2354,11 +2353,11 @@ describe(MediaService.name, () => { }); it('should transcode when policy is required and video is not yuv420p', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStream10Bit); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ @@ -2370,14 +2369,14 @@ describe(MediaService.name, () => { }); it('should count frames for progress when log level is debug', async () => { - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - loggerMock.isLevelEnabled.mockReturnValue(true); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.logger.isLevelEnabled.mockReturnValue(true); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: true }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: true }); + expect(mocks.media.transcode).toHaveBeenCalledWith( assetStub.video.originalPath, 'upload/encoded-video/user-id/as/se/asset-id.mp4', { @@ -2393,20 +2392,20 @@ describe(MediaService.name, () => { }); it('should not count frames for progress when log level is not debug', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - loggerMock.isLevelEnabled.mockReturnValue(false); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.logger.isLevelEnabled.mockReturnValue(false); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: false }); + expect(mocks.media.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: false }); }); it('should process unknown audio stream', async () => { - mediaMock.probe.mockResolvedValue(probeStub.audioStreamUnknown); - assetMock.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.audioStreamUnknown); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( + expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 5555a937f8..54540dff66 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { dirname } from 'node:path'; +import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent, OnJob } from 'src/decorators'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; @@ -10,7 +11,10 @@ import { AssetType, AudioCodec, Colorspace, + JobName, + JobStatus, LogLevel, + QueueName, StorageFolder, TranscodeHWAccel, TranscodePolicy, @@ -18,17 +22,9 @@ import { VideoCodec, VideoContainer, } from 'src/enum'; -import { UpsertFileOptions, WithoutProperty } from 'src/interfaces/asset.interface'; -import { - JOBS_ASSET_PAGINATION_SIZE, - JobItem, - JobName, - JobOf, - JobStatus, - QueueName, -} from 'src/interfaces/job.interface'; +import { UpsertFileOptions, WithoutProperty } from 'src/repositories/asset.repository'; import { BaseService } from 'src/services/base.service'; -import { AudioStreamInfo, VideoFormat, VideoInterfaces, VideoStreamInfo } from 'src/types'; +import { AudioStreamInfo, JobItem, JobOf, VideoFormat, VideoInterfaces, VideoStreamInfo } from 'src/types'; import { getAssetFiles } from 'src/utils/asset.util'; import { BaseConfig, ThumbnailConfig } from 'src/utils/media'; import { mimeTypes } from 'src/utils/mime-types'; @@ -194,7 +190,7 @@ export class MediaService extends BaseService { await Promise.all(pathsToDelete.map((path) => this.storageRepository.unlink(path))); } - if (asset.thumbhash != generated.thumbhash) { + if (!asset.thumbhash || Buffer.compare(asset.thumbhash, generated.thumbhash) !== 0) { await this.assetRepository.update({ id: asset.id, thumbhash: generated.thumbhash }); } diff --git a/server/src/services/memory.service.spec.ts b/server/src/services/memory.service.spec.ts index a5fa6a9cab..22a4199572 100644 --- a/server/src/services/memory.service.spec.ts +++ b/server/src/services/memory.service.spec.ts @@ -1,22 +1,14 @@ import { BadRequestException } from '@nestjs/common'; -import { MemoryType } from 'src/enum'; import { MemoryService } from 'src/services/memory.service'; -import { IMemoryRepository } from 'src/types'; -import { authStub } from 'test/fixtures/auth.stub'; -import { memoryStub } from 'test/fixtures/memory.stub'; -import { userStub } from 'test/fixtures/user.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { factory, newUuid, newUuids } from 'test/small.factory'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(MemoryService.name, () => { let sut: MemoryService; - - let accessMock: IAccessRepositoryMock; - let memoryMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, memoryMock } = newTestService(MemoryService)); + ({ sut, mocks } = newTestService(MemoryService)); }); it('should be defined', () => { @@ -25,185 +17,254 @@ describe(MemoryService.name, () => { describe('search', () => { it('should search memories', async () => { - memoryMock.search.mockResolvedValue([memoryStub.memory1, memoryStub.empty]); - await expect(sut.search(authStub.admin)).resolves.toEqual( + const [userId] = newUuids(); + const asset = factory.asset(); + const memory1 = factory.memory({ ownerId: userId, assets: [asset] }); + const memory2 = factory.memory({ ownerId: userId }); + + mocks.memory.search.mockResolvedValue([memory1, memory2]); + + await expect(sut.search(factory.auth({ id: userId }), {})).resolves.toEqual( expect.arrayContaining([ - expect.objectContaining({ id: 'memory1', assets: expect.any(Array) }), - expect.objectContaining({ id: 'memoryEmpty', assets: [] }), + expect.objectContaining({ id: memory1.id, assets: [expect.objectContaining({ id: asset.id })] }), + expect.objectContaining({ id: memory2.id, assets: [] }), ]), ); }); it('should map ', async () => { - await expect(sut.search(authStub.admin)).resolves.toEqual([]); + mocks.memory.search.mockResolvedValue([]); + + await expect(sut.search(factory.auth(), {})).resolves.toEqual([]); }); }); describe('get', () => { it('should throw an error when no access', async () => { - await expect(sut.get(authStub.admin, 'not-found')).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.get(factory.auth(), 'not-found')).rejects.toBeInstanceOf(BadRequestException); }); it('should throw an error when the memory is not found', async () => { - accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['race-condition'])); - await expect(sut.get(authStub.admin, 'race-condition')).rejects.toBeInstanceOf(BadRequestException); + const [memoryId] = newUuids(); + + mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memoryId])); + mocks.memory.get.mockResolvedValue(void 0); + + await expect(sut.get(factory.auth(), memoryId)).rejects.toBeInstanceOf(BadRequestException); }); it('should get a memory by id', async () => { - memoryMock.get.mockResolvedValue(memoryStub.memory1); - accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); - await expect(sut.get(authStub.admin, 'memory1')).resolves.toMatchObject({ id: 'memory1' }); - expect(memoryMock.get).toHaveBeenCalledWith('memory1'); - expect(accessMock.memory.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['memory1'])); + const userId = newUuid(); + const memory = factory.memory({ ownerId: userId }); + + mocks.memory.get.mockResolvedValue(memory); + mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); + + await expect(sut.get(factory.auth({ id: userId }), memory.id)).resolves.toMatchObject({ id: memory.id }); + + expect(mocks.memory.get).toHaveBeenCalledWith(memory.id); + expect(mocks.access.memory.checkOwnerAccess).toHaveBeenCalledWith(memory.ownerId, new Set([memory.id])); }); }); describe('create', () => { it('should skip assets the user does not have access to', async () => { - memoryMock.create.mockResolvedValue(memoryStub.empty); + const [assetId, userId] = newUuids(); + const memory = factory.memory({ ownerId: userId }); + + mocks.memory.create.mockResolvedValue(memory); + await expect( - sut.create(authStub.admin, { - type: MemoryType.ON_THIS_DAY, - data: { year: 2024 }, - assetIds: ['not-mine'], - memoryAt: new Date(2024), + sut.create(factory.auth({ id: userId }), { + type: memory.type, + data: memory.data, + memoryAt: memory.memoryAt, + isSaved: memory.isSaved, + assetIds: [assetId], }), ).resolves.toMatchObject({ assets: [] }); - expect(memoryMock.create).toHaveBeenCalledWith( + + expect(mocks.memory.create).toHaveBeenCalledWith( { - ownerId: 'admin_id', - memoryAt: expect.any(Date), - type: MemoryType.ON_THIS_DAY, - isSaved: undefined, - sendAt: undefined, - data: { year: 2024 }, + type: memory.type, + data: memory.data, + ownerId: memory.ownerId, + memoryAt: memory.memoryAt, + isSaved: memory.isSaved, }, new Set(), ); }); it('should create a memory', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1'])); - memoryMock.create.mockResolvedValue(memoryStub.memory1); + const [assetId, userId] = newUuids(); + const asset = factory.asset({ id: assetId, ownerId: userId }); + const memory = factory.memory({ assets: [asset] }); + + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.memory.create.mockResolvedValue(memory); + await expect( - sut.create(authStub.admin, { - type: MemoryType.ON_THIS_DAY, - data: { year: 2024 }, - assetIds: ['asset1'], - memoryAt: new Date(2024, 0, 1), + sut.create(factory.auth({ id: userId }), { + type: memory.type, + data: memory.data, + assetIds: memory.assets.map((asset) => asset.id), + memoryAt: memory.memoryAt, }), ).resolves.toBeDefined(); - expect(memoryMock.create).toHaveBeenCalledWith( - expect.objectContaining({ - ownerId: userStub.admin.id, - }), - new Set(['asset1']), + + expect(mocks.memory.create).toHaveBeenCalledWith( + expect.objectContaining({ ownerId: userId }), + new Set([assetId]), ); }); it('should create a memory without assets', async () => { - memoryMock.create.mockResolvedValue(memoryStub.memory1); + const memory = factory.memory(); + + mocks.memory.create.mockResolvedValue(memory); + await expect( - sut.create(authStub.admin, { - type: MemoryType.ON_THIS_DAY, - data: { year: 2024 }, - memoryAt: new Date(2024), - }), + sut.create(factory.auth(), { type: memory.type, data: memory.data, memoryAt: memory.memoryAt }), ).resolves.toBeDefined(); }); }); describe('update', () => { it('should require access', async () => { - await expect(sut.update(authStub.admin, 'not-found', { isSaved: true })).rejects.toBeInstanceOf( + await expect(sut.update(factory.auth(), 'not-found', { isSaved: true })).rejects.toBeInstanceOf( BadRequestException, ); - expect(memoryMock.update).not.toHaveBeenCalled(); + + expect(mocks.memory.update).not.toHaveBeenCalled(); }); it('should update a memory', async () => { - accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); - memoryMock.update.mockResolvedValue(memoryStub.memory1); - await expect(sut.update(authStub.admin, 'memory1', { isSaved: true })).resolves.toBeDefined(); - expect(memoryMock.update).toHaveBeenCalledWith('memory1', expect.objectContaining({ isSaved: true })); + const memory = factory.memory(); + + mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); + mocks.memory.update.mockResolvedValue(memory); + + await expect(sut.update(factory.auth(), memory.id, { isSaved: true })).resolves.toBeDefined(); + + expect(mocks.memory.update).toHaveBeenCalledWith(memory.id, expect.objectContaining({ isSaved: true })); }); }); describe('remove', () => { it('should require access', async () => { - await expect(sut.remove(authStub.admin, 'not-found')).rejects.toBeInstanceOf(BadRequestException); - expect(memoryMock.delete).not.toHaveBeenCalled(); + await expect(sut.remove(factory.auth(), newUuid())).rejects.toBeInstanceOf(BadRequestException); + + expect(mocks.memory.delete).not.toHaveBeenCalled(); }); it('should delete a memory', async () => { - accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); - await expect(sut.remove(authStub.admin, 'memory1')).resolves.toBeUndefined(); - expect(memoryMock.delete).toHaveBeenCalledWith('memory1'); + const memoryId = newUuid(); + + mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memoryId])); + mocks.memory.delete.mockResolvedValue(); + + await expect(sut.remove(factory.auth(), memoryId)).resolves.toBeUndefined(); + + expect(mocks.memory.delete).toHaveBeenCalledWith(memoryId); }); }); describe('addAssets', () => { it('should require memory access', async () => { - await expect(sut.addAssets(authStub.admin, 'not-found', { ids: ['asset1'] })).rejects.toBeInstanceOf( + const [memoryId, assetId] = newUuids(); + + await expect(sut.addAssets(factory.auth(), memoryId, { ids: [assetId] })).rejects.toBeInstanceOf( BadRequestException, ); - expect(memoryMock.addAssetIds).not.toHaveBeenCalled(); + + expect(mocks.memory.addAssetIds).not.toHaveBeenCalled(); }); it('should require asset access', async () => { - accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); - memoryMock.get.mockResolvedValue(memoryStub.memory1); - await expect(sut.addAssets(authStub.admin, 'memory1', { ids: ['not-found'] })).resolves.toEqual([ - { error: 'no_permission', id: 'not-found', success: false }, + const assetId = newUuid(); + const memory = factory.memory(); + + mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); + mocks.memory.get.mockResolvedValue(memory); + mocks.memory.getAssetIds.mockResolvedValue(new Set()); + + await expect(sut.addAssets(factory.auth(), memory.id, { ids: [assetId] })).resolves.toEqual([ + { error: 'no_permission', id: assetId, success: false }, ]); - expect(memoryMock.addAssetIds).not.toHaveBeenCalled(); + + expect(mocks.memory.addAssetIds).not.toHaveBeenCalled(); }); it('should skip assets already in the memory', async () => { - accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); - memoryMock.get.mockResolvedValue(memoryStub.memory1); - memoryMock.getAssetIds.mockResolvedValue(new Set(['asset1'])); - await expect(sut.addAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([ - { error: 'duplicate', id: 'asset1', success: false }, + const asset = factory.asset(); + const memory = factory.memory({ assets: [asset] }); + + mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); + mocks.memory.get.mockResolvedValue(memory); + mocks.memory.getAssetIds.mockResolvedValue(new Set([asset.id])); + + await expect(sut.addAssets(factory.auth(), memory.id, { ids: [asset.id] })).resolves.toEqual([ + { error: 'duplicate', id: asset.id, success: false }, ]); - expect(memoryMock.addAssetIds).not.toHaveBeenCalled(); + + expect(mocks.memory.addAssetIds).not.toHaveBeenCalled(); }); it('should add assets', async () => { - accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1'])); - memoryMock.get.mockResolvedValue(memoryStub.memory1); - await expect(sut.addAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([ - { id: 'asset1', success: true }, + const assetId = newUuid(); + const memory = factory.memory(); + + mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId])); + mocks.memory.get.mockResolvedValue(memory); + mocks.memory.update.mockResolvedValue(memory); + mocks.memory.getAssetIds.mockResolvedValue(new Set()); + mocks.memory.addAssetIds.mockResolvedValue(); + + await expect(sut.addAssets(factory.auth(), memory.id, { ids: [assetId] })).resolves.toEqual([ + { id: assetId, success: true }, ]); - expect(memoryMock.addAssetIds).toHaveBeenCalledWith('memory1', ['asset1']); + + expect(mocks.memory.addAssetIds).toHaveBeenCalledWith(memory.id, [assetId]); }); }); describe('removeAssets', () => { it('should require memory access', async () => { - await expect(sut.removeAssets(authStub.admin, 'not-found', { ids: ['asset1'] })).rejects.toBeInstanceOf( + await expect(sut.removeAssets(factory.auth(), 'not-found', { ids: ['asset1'] })).rejects.toBeInstanceOf( BadRequestException, ); - expect(memoryMock.removeAssetIds).not.toHaveBeenCalled(); + + expect(mocks.memory.removeAssetIds).not.toHaveBeenCalled(); }); it('should skip assets not in the memory', async () => { - accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); - await expect(sut.removeAssets(authStub.admin, 'memory1', { ids: ['not-found'] })).resolves.toEqual([ + mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); + mocks.memory.getAssetIds.mockResolvedValue(new Set()); + + await expect(sut.removeAssets(factory.auth(), 'memory1', { ids: ['not-found'] })).resolves.toEqual([ { error: 'not_found', id: 'not-found', success: false }, ]); - expect(memoryMock.removeAssetIds).not.toHaveBeenCalled(); + + expect(mocks.memory.removeAssetIds).not.toHaveBeenCalled(); }); it('should remove assets', async () => { - accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1'])); - memoryMock.getAssetIds.mockResolvedValue(new Set(['asset1'])); - await expect(sut.removeAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([ - { id: 'asset1', success: true }, + const memory = factory.memory(); + const asset = factory.asset(); + + mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.memory.getAssetIds.mockResolvedValue(new Set([asset.id])); + mocks.memory.removeAssetIds.mockResolvedValue(); + mocks.memory.update.mockResolvedValue(memory); + + await expect(sut.removeAssets(factory.auth(), memory.id, { ids: [asset.id] })).resolves.toEqual([ + { id: asset.id, success: true }, ]); - expect(memoryMock.removeAssetIds).toHaveBeenCalledWith('memory1', ['asset1']); + + expect(mocks.memory.removeAssetIds).toHaveBeenCalledWith(memory.id, [asset.id]); }); }); }); diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index e3aa1f3574..28c90f6576 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -1,16 +1,79 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { JsonObject } from 'src/db'; +import { DateTime } from 'luxon'; +import { OnJob } from 'src/decorators'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { MemoryCreateDto, MemoryResponseDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto'; -import { Permission } from 'src/enum'; +import { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto'; +import { OnThisDayData } from 'src/entities/memory.entity'; +import { JobName, MemoryType, Permission, QueueName, SystemMetadataKey } from 'src/enum'; import { BaseService } from 'src/services/base.service'; -import { addAssets, removeAssets } from 'src/utils/asset.util'; +import { addAssets, getMyPartnerIds, removeAssets } from 'src/utils/asset.util'; + +const DAYS = 3; @Injectable() export class MemoryService extends BaseService { - async search(auth: AuthDto) { - const memories = await this.memoryRepository.search(auth.user.id); + @OnJob({ name: JobName.MEMORIES_CREATE, queue: QueueName.BACKGROUND_TASK }) + async onMemoriesCreate() { + const users = await this.userRepository.getList({ withDeleted: false }); + const userMap: Record = {}; + for (const user of users) { + const partnerIds = await getMyPartnerIds({ + userId: user.id, + repository: this.partnerRepository, + timelineEnabled: true, + }); + userMap[user.id] = [user.id, ...partnerIds]; + } + + const start = DateTime.utc().startOf('day').minus({ days: DAYS }); + + const state = await this.systemMetadataRepository.get(SystemMetadataKey.MEMORIES_STATE); + const lastOnThisDayDate = state?.lastOnThisDayDate ? DateTime.fromISO(state.lastOnThisDayDate) : start; + + // generate a memory +/- X days from today + for (let i = 0; i <= DAYS * 2; i++) { + const target = start.plus({ days: i }); + if (lastOnThisDayDate >= target) { + continue; + } + + const showAt = target.startOf('day').toISO(); + const hideAt = target.endOf('day').toISO(); + + for (const [userId, userIds] of Object.entries(userMap)) { + const memories = await this.assetRepository.getByDayOfYear(userIds, target); + + for (const { year, assets } of memories) { + const data: OnThisDayData = { year }; + await this.memoryRepository.create( + { + ownerId: userId, + type: MemoryType.ON_THIS_DAY, + data, + memoryAt: target.set({ year }).toISO(), + showAt, + hideAt, + }, + new Set(assets.map(({ id }) => id)), + ); + } + } + + await this.systemMetadataRepository.set(SystemMetadataKey.MEMORIES_STATE, { + ...state, + lastOnThisDayDate: target.toISO(), + }); + } + } + + @OnJob({ name: JobName.MEMORIES_CLEANUP, queue: QueueName.BACKGROUND_TASK }) + async onMemoriesCleanup() { + await this.memoryRepository.cleanup(); + } + + async search(auth: AuthDto, dto: MemorySearchDto) { + const memories = await this.memoryRepository.search(auth.user.id, dto); return memories.map((memory) => mapMemory(memory)); } @@ -33,7 +96,7 @@ export class MemoryService extends BaseService { { ownerId: auth.user.id, type: dto.type, - data: dto.data as unknown as JsonObject, + data: dto.data, isSaved: dto.isSaved, memoryAt: dto.memoryAt, seenAt: dto.seenAt, diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 8cc6e014d2..229b63f20e 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1,78 +1,43 @@ import { BinaryField, ExifDateTime } from 'exiftool-vendored'; import { randomBytes } from 'node:crypto'; -import { Stats } from 'node:fs'; import { constants } from 'node:fs/promises'; +import { defaults } from 'src/config'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; -import { AssetType, ExifOrientation, ImmichWorker, SourceType } from 'src/enum'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { ITagRepository } from 'src/interfaces/tag.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { AssetType, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum'; +import { WithoutProperty } from 'src/repositories/asset.repository'; import { ImmichTags } from 'src/repositories/metadata.repository'; import { MetadataService } from 'src/services/metadata.service'; -import { IConfigRepository, IMapRepository, IMediaRepository, IMetadataRepository } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { probeStub } from 'test/fixtures/media.stub'; import { metadataStub } from 'test/fixtures/metadata.stub'; import { personStub } from 'test/fixtures/person.stub'; import { tagStub } from 'test/fixtures/tag.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(MetadataService.name, () => { let sut: MetadataService; - - let albumMock: Mocked; - let assetMock: Mocked; - let configMock: Mocked; - let cryptoMock: Mocked; - let eventMock: Mocked; - let jobMock: Mocked; - let mapMock: Mocked; - let mediaMock: Mocked; - let metadataMock: Mocked; - let personMock: Mocked; - let storageMock: Mocked; - let systemMock: Mocked; - let tagMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; const mockReadTags = (exifData?: Partial, sidecarData?: Partial) => { - metadataMock.readTags.mockReset(); - metadataMock.readTags.mockResolvedValueOnce(exifData ?? {}); - metadataMock.readTags.mockResolvedValueOnce(sidecarData ?? {}); + exifData = { + FileSize: '123456', + FileCreateDate: '2024-01-01T00:00:00.000Z', + FileModifyDate: '2024-01-01T00:00:00.000Z', + ...exifData, + }; + mocks.metadata.readTags.mockReset(); + mocks.metadata.readTags.mockResolvedValueOnce(exifData); + mocks.metadata.readTags.mockResolvedValueOnce(sidecarData ?? {}); }; beforeEach(() => { - ({ - sut, - albumMock, - assetMock, - configMock, - cryptoMock, - eventMock, - jobMock, - mapMock, - mediaMock, - metadataMock, - personMock, - storageMock, - systemMock, - tagMock, - userMock, - } = newTestService(MetadataService)); + ({ sut, mocks } = newTestService(MetadataService)); mockReadTags(); - configMock.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); + mocks.config.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); delete process.env.TZ; }); @@ -87,140 +52,46 @@ describe(MetadataService.name, () => { describe('onBootstrapEvent', () => { it('should pause and resume queue during init', async () => { + mocks.job.pause.mockResolvedValue(); + mocks.map.init.mockResolvedValue(); + mocks.job.resume.mockResolvedValue(); + await sut.onBootstrap(); - expect(jobMock.pause).toHaveBeenCalledTimes(1); - expect(mapMock.init).toHaveBeenCalledTimes(1); - expect(jobMock.resume).toHaveBeenCalledTimes(1); + expect(mocks.job.pause).toHaveBeenCalledTimes(1); + expect(mocks.map.init).toHaveBeenCalledTimes(1); + expect(mocks.job.resume).toHaveBeenCalledTimes(1); }); }); - describe('handleLivePhotoLinking', () => { - it('should handle an asset that could not be found', async () => { - await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); - expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(albumMock.removeAsset).not.toHaveBeenCalled(); + describe('onConfigInit', () => { + it('should update metadata processing concurrency', () => { + sut.onConfigInit({ newConfig: defaults }); + + expect(mocks.metadata.setMaxConcurrency).toHaveBeenCalledWith(defaults.job.metadataExtraction.concurrency); + expect(mocks.metadata.setMaxConcurrency).toHaveBeenCalledTimes(1); }); + }); - it('should handle an asset without exif info', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, exifInfo: undefined }]); + describe('onConfigUpdate', () => { + it('should update metadata processing concurrency', () => { + const newConfig = structuredClone(defaults); + newConfig.job.metadataExtraction.concurrency = 10; - await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); - expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(albumMock.removeAsset).not.toHaveBeenCalled(); - }); + sut.onConfigUpdate({ oldConfig: defaults, newConfig }); - it('should handle livePhotoCID not set', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.image }]); - - await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(JobStatus.SKIPPED); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); - expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(albumMock.removeAsset).not.toHaveBeenCalled(); - }); - - it('should handle not finding a match', async () => { - assetMock.getByIds.mockResolvedValue([ - { - ...assetStub.livePhotoMotionAsset, - exifInfo: { livePhotoCID: assetStub.livePhotoStillAsset.id } as ExifEntity, - }, - ]); - - await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoMotionAsset.id })).resolves.toBe( - JobStatus.SKIPPED, - ); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { exifInfo: true }); - expect(assetMock.findLivePhotoMatch).toHaveBeenCalledWith({ - livePhotoCID: assetStub.livePhotoStillAsset.id, - ownerId: assetStub.livePhotoMotionAsset.ownerId, - otherAssetId: assetStub.livePhotoMotionAsset.id, - type: AssetType.IMAGE, - }); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(albumMock.removeAsset).not.toHaveBeenCalled(); - }); - - it('should link photo and video', async () => { - assetMock.getByIds.mockResolvedValue([ - { - ...assetStub.livePhotoStillAsset, - exifInfo: { livePhotoCID: assetStub.livePhotoMotionAsset.id } as ExifEntity, - }, - ]); - assetMock.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); - - await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( - JobStatus.SUCCESS, - ); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id], { exifInfo: true }); - expect(assetMock.findLivePhotoMatch).toHaveBeenCalledWith({ - livePhotoCID: assetStub.livePhotoMotionAsset.id, - ownerId: assetStub.livePhotoStillAsset.ownerId, - otherAssetId: assetStub.livePhotoStillAsset.id, - type: AssetType.VIDEO, - }); - expect(assetMock.update).toHaveBeenCalledWith({ - id: assetStub.livePhotoStillAsset.id, - livePhotoVideoId: assetStub.livePhotoMotionAsset.id, - }); - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false }); - expect(albumMock.removeAsset).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id); - }); - - it('should notify clients on live photo link', async () => { - assetMock.getByIds.mockResolvedValue([ - { - ...assetStub.livePhotoStillAsset, - exifInfo: { livePhotoCID: assetStub.livePhotoMotionAsset.id } as ExifEntity, - }, - ]); - assetMock.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); - - await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( - JobStatus.SUCCESS, - ); - expect(eventMock.emit).toHaveBeenCalledWith('asset.hide', { - userId: assetStub.livePhotoMotionAsset.ownerId, - assetId: assetStub.livePhotoMotionAsset.id, - }); - }); - - it('should search by libraryId', async () => { - assetMock.getByIds.mockResolvedValue([ - { - ...assetStub.livePhotoStillAsset, - libraryId: 'library-id', - exifInfo: { livePhotoCID: 'CID' } as ExifEntity, - }, - ]); - - await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( - JobStatus.SKIPPED, - ); - - expect(assetMock.findLivePhotoMatch).toHaveBeenCalledWith({ - ownerId: 'user-id', - otherAssetId: 'live-photo-still-asset', - livePhotoCID: 'CID', - libraryId: 'library-id', - type: 'VIDEO', - }); + expect(mocks.metadata.setMaxConcurrency).toHaveBeenCalledWith(newConfig.job.metadataExtraction.concurrency); + expect(mocks.metadata.setMaxConcurrency).toHaveBeenCalledTimes(1); }); }); describe('handleQueueMetadataExtraction', () => { it('should queue metadata extraction for all assets without exif values', async () => { - assetMock.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); await expect(sut.handleQueueMetadataExtraction({ force: false })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getWithout).toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getWithout).toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.METADATA_EXTRACTION, data: { id: assetStub.image.id }, @@ -229,11 +100,11 @@ describe(MetadataService.name, () => { }); it('should queue metadata extraction for all assets', async () => { - assetMock.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); await expect(sut.handleQueueMetadataExtraction({ force: true })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.METADATA_EXTRACTION, data: { id: assetStub.image.id }, @@ -243,82 +114,90 @@ describe(MetadataService.name, () => { }); describe('handleMetadataExtraction', () => { - beforeEach(() => { - storageMock.stat.mockResolvedValue({ size: 123_456 } as Stats); - }); - it('should handle an asset that could not be found', async () => { await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); - expect(assetMock.upsertExif).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should handle a date in a sidecar file', async () => { const originalDate = new Date('2023-11-21T16:13:17.517Z'); const sidecarDate = new Date('2022-01-01T00:00:00.000Z'); - assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); mockReadTags({ CreationDate: originalDate.toISOString() }, { CreationDate: sidecarDate.toISOString() }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.sidecar.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: sidecarDate })); - expect(assetMock.update).toHaveBeenCalledWith({ - id: assetStub.image.id, - duration: null, - fileCreatedAt: sidecarDate, - localDateTime: sidecarDate, - }); + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.sidecar.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: sidecarDate })); + expect(mocks.asset.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: assetStub.image.id, + duration: null, + fileCreatedAt: sidecarDate, + localDateTime: sidecarDate, + }), + ); }); - it('should take the file modification date when missing exif and earliest than creation date', async () => { + it('should take the file modification date when missing exif and earlier than creation date', async () => { const fileCreatedAt = new Date('2022-01-01T00:00:00.000Z'); const fileModifiedAt = new Date('2021-01-01T00:00:00.000Z'); - assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, fileCreatedAt, fileModifiedAt }]); - mockReadTags(); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mockReadTags({ + FileCreateDate: fileCreatedAt.toISOString(), + FileModifyDate: fileModifiedAt.toISOString(), + }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: fileModifiedAt })); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ dateTimeOriginal: fileModifiedAt }), + ); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, duration: null, fileCreatedAt: fileModifiedAt, + fileModifiedAt, localDateTime: fileModifiedAt, }); }); - it('should take the file creation date when missing exif and earliest than modification date', async () => { + it('should take the file creation date when missing exif and earlier than modification date', async () => { const fileCreatedAt = new Date('2021-01-01T00:00:00.000Z'); const fileModifiedAt = new Date('2022-01-01T00:00:00.000Z'); - assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, fileCreatedAt, fileModifiedAt }]); - mockReadTags(); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mockReadTags({ + FileCreateDate: fileCreatedAt.toISOString(), + FileModifyDate: fileModifiedAt.toISOString(), + }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: fileCreatedAt })); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: fileCreatedAt })); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, duration: null, fileCreatedAt, + fileModifiedAt, localDateTime: fileCreatedAt, }); }); it('should account for the server being in a non-UTC timezone', async () => { process.env.TZ = 'America/Los_Angeles'; - assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); mockReadTags({ DateTimeOriginal: '2022:01:01 00:00:00' }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ dateTimeOriginal: new Date('2022-01-01T08:00:00.000Z'), }), ); - expect(assetMock.update).toHaveBeenCalledWith( + expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ localDateTime: new Date('2022-01-01T00:00:00.000Z'), }), @@ -326,177 +205,201 @@ describe(MetadataService.name, () => { }); it('should handle lists of numbers', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - mockReadTags({ ISO: [160] }); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mockReadTags({ + ISO: [160], + FileCreateDate: assetStub.image.fileCreatedAt.toISOString(), + FileModifyDate: assetStub.image.fileModifiedAt.toISOString(), + }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ iso: 160 })); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ iso: 160 })); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, duration: null, - fileCreatedAt: assetStub.image.createdAt, - localDateTime: new Date('2023-02-23T05:06:29.716Z'), + fileCreatedAt: assetStub.image.fileCreatedAt, + fileModifiedAt: assetStub.image.fileCreatedAt, + localDateTime: assetStub.image.fileCreatedAt, }); }); it('should apply reverse geocoding', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.withLocation]); - systemMock.get.mockResolvedValue({ reverseGeocoding: { enabled: true } }); - mapMock.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' }); + mocks.asset.getByIds.mockResolvedValue([assetStub.withLocation]); + mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: true } }); + mocks.map.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' }); mockReadTags({ GPSLatitude: assetStub.withLocation.exifInfo!.latitude!, GPSLongitude: assetStub.withLocation.exifInfo!.longitude!, + FileCreateDate: assetStub.withLocation.fileCreatedAt.toISOString(), + FileModifyDate: assetStub.withLocation.fileModifiedAt.toISOString(), }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ city: 'City', state: 'State', country: 'Country' }), ); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.withLocation.id, duration: null, - fileCreatedAt: assetStub.withLocation.createdAt, + fileCreatedAt: assetStub.withLocation.fileCreatedAt, + fileModifiedAt: assetStub.withLocation.fileModifiedAt, localDateTime: new Date('2023-02-22T05:06:29.716Z'), }); }); it('should discard latitude and longitude on null island', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.withLocation]); + mocks.asset.getByIds.mockResolvedValue([assetStub.withLocation]); mockReadTags({ GPSLatitude: 0, GPSLongitude: 0, }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ latitude: null, longitude: null })); + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ latitude: null, longitude: null })); }); it('should extract tags from TagsList', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ TagsList: ['Parent'] }); - tagMock.upsertValue.mockResolvedValue(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); }); it('should extract hierarchy from TagsList', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ TagsList: ['Parent/Child'] }); - tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent); - tagMock.upsertValue.mockResolvedValueOnce(tagStub.child); + mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); + mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { + userId: 'user-id', + value: 'Parent', + parentId: undefined, + }); + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { userId: 'user-id', value: 'Parent/Child', - parent: tagStub.parent, + parentId: 'tag-parent', }); }); it('should extract tags from Keywords as a string', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Keywords: 'Parent' }); - tagMock.upsertValue.mockResolvedValue(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); }); it('should extract tags from Keywords as a list', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Keywords: ['Parent'] }); - tagMock.upsertValue.mockResolvedValue(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); }); it('should extract tags from Keywords as a list with a number', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Keywords: ['Parent', 2024] }); - tagMock.upsertValue.mockResolvedValue(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); - expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: '2024', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: '2024', parent: undefined }); }); it('should extract hierarchal tags from Keywords', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Keywords: 'Parent/Child' }); - tagMock.upsertValue.mockResolvedValue(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { + userId: 'user-id', + value: 'Parent', + parentId: undefined, + }); + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { userId: 'user-id', value: 'Parent/Child', - parent: tagStub.parent, + parentId: 'tag-parent', }); }); it('should ignore Keywords when TagsList is present', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] }); - tagMock.upsertValue.mockResolvedValue(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { + userId: 'user-id', + value: 'Parent', + parentId: undefined, + }); + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { userId: 'user-id', value: 'Parent/Child', - parent: tagStub.parent, + parentId: 'tag-parent', }); }); it('should extract hierarchy from HierarchicalSubject', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] }); - tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent); - tagMock.upsertValue.mockResolvedValueOnce(tagStub.child); + mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); + mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { + userId: 'user-id', + value: 'Parent', + parentId: undefined, + }); + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { userId: 'user-id', value: 'Parent/Child', - parent: tagStub.parent, + parentId: 'tag-parent', }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(3, { userId: 'user-id', value: 'TagA', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(3, { userId: 'user-id', value: 'TagA', parent: undefined }); }); it('should extract tags from HierarchicalSubject as a list with a number', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ HierarchicalSubject: ['Parent', 2024] }); - tagMock.upsertValue.mockResolvedValue(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); - expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: '2024', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: '2024', parent: undefined }); }); it('should extract ignore / characters in a HierarchicalSubject tag', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ HierarchicalSubject: ['Mom/Dad'] }); - tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenCalledWith({ + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Mom|Dad', parent: undefined, @@ -504,47 +407,51 @@ describe(MetadataService.name, () => { }); it('should ignore HierarchicalSubject when TagsList is present', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] }); - tagMock.upsertValue.mockResolvedValue(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { + userId: 'user-id', + value: 'Parent', + parentId: undefined, + }); + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { userId: 'user-id', value: 'Parent/Child', - parent: tagStub.parent, + parentId: 'tag-parent', }); }); it('should remove existing tags', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({}); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(tagMock.upsertAssetTags).toHaveBeenCalledWith({ assetId: 'asset-id', tagIds: [] }); + expect(mocks.tag.replaceAssetTags).toHaveBeenCalledWith('asset-id', []); }); it('should not apply motion photos if asset is video', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoMotionAsset, isVisible: true }]); - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoMotionAsset, isVisible: true }]); + mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); await sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { faces: { person: false }, }); - expect(storageMock.createOrOverwriteFile).not.toHaveBeenCalled(); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith( + expect(mocks.storage.createOrOverwriteFile).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalledWith( expect.objectContaining({ assetType: AssetType.VIDEO, isVisible: false }), ); }); it('should handle an invalid Directory Item', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ MotionPhoto: 1, ContainerDirectory: [{ Foo: 100 }], @@ -554,43 +461,46 @@ describe(MetadataService.name, () => { }); it('should extract the correct video orientation', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.video]); - mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); mockReadTags({}); await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ orientation: ExifOrientation.Rotate270CW.toString() }), ); }); it('should extract the MotionPhotoVideo tag from Samsung HEIC motion photos', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); mockReadTags({ Directory: 'foo/bar/', + MotionPhoto: 1, MotionPhotoVideo: new BinaryField(0, ''), // The below two are included to ensure that the MotionPhotoVideo tag is extracted // instead of the EmbeddedVideoFile, since HEIC MotionPhotos include both EmbeddedVideoFile: new BinaryField(0, ''), EmbeddedVideoType: 'MotionPhoto_Data', + FileCreateDate: assetStub.livePhotoWithOriginalFileName.fileCreatedAt.toISOString(), + FileModifyDate: assetStub.livePhotoWithOriginalFileName.fileModifiedAt.toISOString(), }); - cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); - assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset); - cryptoMock.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); + mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); + mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.crypto.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); const video = randomBytes(512); - metadataMock.extractBinaryTag.mockResolvedValue(video); + mocks.metadata.extractBinaryTag.mockResolvedValue(video); await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id }); - expect(metadataMock.extractBinaryTag).toHaveBeenCalledWith( + expect(mocks.metadata.extractBinaryTag).toHaveBeenCalledWith( assetStub.livePhotoWithOriginalFileName.originalPath, 'MotionPhotoVideo', ); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { faces: { person: false }, }); - expect(assetMock.create).toHaveBeenCalledWith({ + expect(mocks.asset.create).toHaveBeenCalledWith({ checksum: expect.any(Buffer), deviceAssetId: 'NONE', deviceId: 'NONE', @@ -605,36 +515,40 @@ describe(MetadataService.name, () => { ownerId: assetStub.livePhotoWithOriginalFileName.ownerId, type: AssetType.VIDEO, }); - expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); - expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); - expect(assetMock.update).toHaveBeenNthCalledWith(1, { + expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); + expect(mocks.storage.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoWithOriginalFileName.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, }); + expect(mocks.asset.update).toHaveBeenCalledTimes(2); }); it('should extract the EmbeddedVideo tag from Samsung JPEG motion photos', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); mockReadTags({ Directory: 'foo/bar/', EmbeddedVideoFile: new BinaryField(0, ''), EmbeddedVideoType: 'MotionPhoto_Data', + MotionPhoto: 1, + FileCreateDate: assetStub.livePhotoWithOriginalFileName.fileCreatedAt.toISOString(), + FileModifyDate: assetStub.livePhotoWithOriginalFileName.fileModifiedAt.toISOString(), }); - cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); - assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset); - cryptoMock.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); + mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); + mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.crypto.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); const video = randomBytes(512); - metadataMock.extractBinaryTag.mockResolvedValue(video); + mocks.metadata.extractBinaryTag.mockResolvedValue(video); await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id }); - expect(metadataMock.extractBinaryTag).toHaveBeenCalledWith( + expect(mocks.metadata.extractBinaryTag).toHaveBeenCalledWith( assetStub.livePhotoWithOriginalFileName.originalPath, 'EmbeddedVideoFile', ); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { faces: { person: false }, }); - expect(assetMock.create).toHaveBeenCalledWith({ + expect(mocks.asset.create).toHaveBeenCalledWith({ checksum: expect.any(Buffer), deviceAssetId: 'NONE', deviceId: 'NONE', @@ -649,37 +563,40 @@ describe(MetadataService.name, () => { ownerId: assetStub.livePhotoWithOriginalFileName.ownerId, type: AssetType.VIDEO, }); - expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); - expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); - expect(assetMock.update).toHaveBeenNthCalledWith(1, { + expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); + expect(mocks.storage.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoWithOriginalFileName.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, }); + expect(mocks.asset.update).toHaveBeenCalledTimes(2); }); it('should extract the motion photo video from the XMP directory entry ', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, MicroVideo: 1, MicroVideoOffset: 1, + FileCreateDate: assetStub.livePhotoWithOriginalFileName.fileCreatedAt.toISOString(), + FileModifyDate: assetStub.livePhotoWithOriginalFileName.fileModifiedAt.toISOString(), }); - cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); - assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset); - cryptoMock.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); + mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); + mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.crypto.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); const video = randomBytes(512); - storageMock.readFile.mockResolvedValue(video); + mocks.storage.readFile.mockResolvedValue(video); await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { faces: { person: false }, }); - expect(storageMock.readFile).toHaveBeenCalledWith( + expect(mocks.storage.readFile).toHaveBeenCalledWith( assetStub.livePhotoWithOriginalFileName.originalPath, expect.any(Object), ); - expect(assetMock.create).toHaveBeenCalledWith({ + expect(mocks.asset.create).toHaveBeenCalledWith({ checksum: expect.any(Buffer), deviceAssetId: 'NONE', deviceId: 'NONE', @@ -694,88 +611,90 @@ describe(MetadataService.name, () => { ownerId: assetStub.livePhotoWithOriginalFileName.ownerId, type: AssetType.VIDEO, }); - expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); - expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); - expect(assetMock.update).toHaveBeenNthCalledWith(1, { + expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); + expect(mocks.storage.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoWithOriginalFileName.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, }); + expect(mocks.asset.update).toHaveBeenCalledTimes(2); }); it('should delete old motion photo video assets if they do not match what is extracted', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoWithOriginalFileName]); + mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoWithOriginalFileName]); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, MicroVideo: 1, MicroVideoOffset: 1, }); - cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); - assetMock.create.mockImplementation( + mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); + mocks.asset.create.mockImplementation( (asset) => Promise.resolve({ ...assetStub.livePhotoMotionAsset, ...asset }) as Promise, ); const video = randomBytes(512); - storageMock.readFile.mockResolvedValue(video); + mocks.storage.readFile.mockResolvedValue(video); await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id }); - expect(jobMock.queue).toHaveBeenNthCalledWith(1, { + expect(mocks.job.queue).toHaveBeenNthCalledWith(1, { name: JobName.ASSET_DELETION, data: { id: assetStub.livePhotoWithOriginalFileName.livePhotoVideoId, deleteOnDisk: true }, }); - expect(jobMock.queue).toHaveBeenNthCalledWith(2, { + expect(mocks.job.queue).toHaveBeenNthCalledWith(2, { name: JobName.METADATA_EXTRACTION, data: { id: 'random-uuid' }, }); }); it('should not create a new motion photo video asset if the hash of the extracted video matches an existing asset', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]); + mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, MicroVideo: 1, MicroVideoOffset: 1, }); - cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); - assetMock.getByChecksum.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); + mocks.asset.getByChecksum.mockResolvedValue(assetStub.livePhotoMotionAsset); const video = randomBytes(512); - storageMock.readFile.mockResolvedValue(video); - storageMock.checkFileExists.mockResolvedValue(true); + mocks.storage.readFile.mockResolvedValue(video); + mocks.storage.checkFileExists.mockResolvedValue(true); await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); - expect(assetMock.create).toHaveBeenCalledTimes(0); - expect(storageMock.createOrOverwriteFile).toHaveBeenCalledTimes(0); + expect(mocks.asset.create).not.toHaveBeenCalled(); + expect(mocks.storage.createOrOverwriteFile).not.toHaveBeenCalled(); // The still asset gets saved by handleMetadataExtraction, but not the video - expect(assetMock.update).toHaveBeenCalledTimes(1); - expect(jobMock.queue).toHaveBeenCalledTimes(0); + expect(mocks.asset.update).toHaveBeenCalledTimes(1); + expect(mocks.job.queue).not.toHaveBeenCalled(); }); it('should link and hide motion video asset to still asset if the hash of the extracted video matches an existing asset', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, MicroVideo: 1, MicroVideoOffset: 1, }); - cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); - assetMock.getByChecksum.mockResolvedValue({ ...assetStub.livePhotoMotionAsset, isVisible: true }); + mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); + mocks.asset.getByChecksum.mockResolvedValue({ ...assetStub.livePhotoMotionAsset, isVisible: true }); const video = randomBytes(512); - storageMock.readFile.mockResolvedValue(video); + mocks.storage.readFile.mockResolvedValue(video); await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); - expect(assetMock.update).toHaveBeenNthCalledWith(1, { + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false, }); - expect(assetMock.update).toHaveBeenNthCalledWith(2, { + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }); + expect(mocks.asset.update).toHaveBeenCalledTimes(3); }); it('should not update storage usage if motion photo is external', async () => { - assetMock.getByIds.mockResolvedValue([ + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.livePhotoStillAsset, livePhotoVideoId: null, isExternal: true }, ]); mockReadTags({ @@ -784,13 +703,13 @@ describe(MetadataService.name, () => { MicroVideo: 1, MicroVideoOffset: 1, }); - cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); - assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); + mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset); const video = randomBytes(512); - storageMock.readFile.mockResolvedValue(video); + mocks.storage.readFile.mockResolvedValue(video); await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); - expect(userMock.updateUsage).not.toHaveBeenCalled(); + expect(mocks.user.updateUsage).not.toHaveBeenCalled(); }); it('should save all metadata', async () => { @@ -819,12 +738,12 @@ describe(MetadataService.name, () => { tz: 'UTC-11:30', Rating: 3, }; - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags(tags); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalledWith({ + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith({ assetId: assetStub.image.id, bitsPerSample: expect.any(Number), autoStackId: null, @@ -855,12 +774,14 @@ describe(MetadataService.name, () => { state: null, city: null, }); - expect(assetMock.update).toHaveBeenCalledWith({ - id: assetStub.image.id, - duration: null, - fileCreatedAt: dateForTest, - localDateTime: dateForTest, - }); + expect(mocks.asset.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: assetStub.image.id, + duration: null, + fileCreatedAt: dateForTest, + localDateTime: dateForTest, + }), + ); }); it('should extract +00:00 timezone from raw value', async () => { @@ -877,12 +798,12 @@ describe(MetadataService.name, () => { DateTimeOriginal: ExifDateTime.fromISO(someDate + '+00:00'), tz: undefined, }; - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags(tags); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ timeZone: 'UTC+0', }), @@ -890,8 +811,8 @@ describe(MetadataService.name, () => { }); it('should extract duration', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.video }]); - mediaMock.probe.mockResolvedValue({ + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.video }]); + mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { ...probeStub.videoStreamH264.format, @@ -901,9 +822,9 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalled(); - expect(assetMock.update).toHaveBeenCalledWith( + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalled(); + expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ id: assetStub.image.id, duration: '00:00:06.210', @@ -912,8 +833,8 @@ describe(MetadataService.name, () => { }); it('should only extract duration for videos', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.image }]); - mediaMock.probe.mockResolvedValue({ + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image }]); + mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { ...probeStub.videoStreamH264.format, @@ -922,9 +843,9 @@ describe(MetadataService.name, () => { }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalled(); - expect(assetMock.update).toHaveBeenCalledWith( + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalled(); + expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ id: assetStub.image.id, duration: null, @@ -933,8 +854,8 @@ describe(MetadataService.name, () => { }); it('should omit duration of zero', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.video }]); - mediaMock.probe.mockResolvedValue({ + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.video }]); + mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { ...probeStub.videoStreamH264.format, @@ -944,9 +865,9 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalled(); - expect(assetMock.update).toHaveBeenCalledWith( + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalled(); + expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ id: assetStub.image.id, duration: null, @@ -955,8 +876,8 @@ describe(MetadataService.name, () => { }); it('should a handle duration of 1 week', async () => { - assetMock.getByIds.mockResolvedValue([{ ...assetStub.video }]); - mediaMock.probe.mockResolvedValue({ + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.video }]); + mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { ...probeStub.videoStreamH264.format, @@ -966,9 +887,9 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); - expect(assetMock.upsertExif).toHaveBeenCalled(); - expect(assetMock.update).toHaveBeenCalledWith( + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); + expect(mocks.asset.upsertExif).toHaveBeenCalled(); + expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ id: assetStub.video.id, duration: '168:00:00.000', @@ -977,19 +898,19 @@ describe(MetadataService.name, () => { }); it('should ignore duration from exif data', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({}, { Duration: { Value: 123 } }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.update).toHaveBeenCalledWith(expect.objectContaining({ duration: null })); + expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: null })); }); it('should trim whitespace from description', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Description: '\t \v \f \n \r' }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ description: '', }), @@ -997,7 +918,7 @@ describe(MetadataService.name, () => { mockReadTags({ ImageDescription: ' my\n description' }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ description: 'my\n description', }), @@ -1005,11 +926,11 @@ describe(MetadataService.name, () => { }); it('should handle a numeric description', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Description: 1000 }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ description: '1000', }), @@ -1017,57 +938,59 @@ describe(MetadataService.name, () => { }); it('should skip importing metadata when the feature is disabled', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); - systemMock.get.mockResolvedValue({ metadata: { faces: { import: false } } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: false } } }); mockReadTags(metadataStub.withFace); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(personMock.getDistinctNames).not.toHaveBeenCalled(); + expect(mocks.person.getDistinctNames).not.toHaveBeenCalled(); }); it('should skip importing metadata face for assets without tags.RegionInfo', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); - systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(metadataStub.empty); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(personMock.getDistinctNames).not.toHaveBeenCalled(); + expect(mocks.person.getDistinctNames).not.toHaveBeenCalled(); }); it('should skip importing faces without name', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); - systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(metadataStub.withFaceNoName); - personMock.getDistinctNames.mockResolvedValue([]); - personMock.createAll.mockResolvedValue([]); + mocks.person.getDistinctNames.mockResolvedValue([]); + mocks.person.createAll.mockResolvedValue([]); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(personMock.createAll).not.toHaveBeenCalled(); - expect(personMock.refreshFaces).not.toHaveBeenCalled(); - expect(personMock.updateAll).not.toHaveBeenCalled(); + expect(mocks.person.createAll).not.toHaveBeenCalled(); + expect(mocks.person.refreshFaces).not.toHaveBeenCalled(); + expect(mocks.person.updateAll).not.toHaveBeenCalled(); }); it('should skip importing faces with empty name', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); - systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(metadataStub.withFaceEmptyName); - personMock.getDistinctNames.mockResolvedValue([]); - personMock.createAll.mockResolvedValue([]); + mocks.person.getDistinctNames.mockResolvedValue([]); + mocks.person.createAll.mockResolvedValue([]); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(personMock.createAll).not.toHaveBeenCalled(); - expect(personMock.refreshFaces).not.toHaveBeenCalled(); - expect(personMock.updateAll).not.toHaveBeenCalled(); + expect(mocks.person.createAll).not.toHaveBeenCalled(); + expect(mocks.person.refreshFaces).not.toHaveBeenCalled(); + expect(mocks.person.updateAll).not.toHaveBeenCalled(); }); it('should apply metadata face tags creating new persons', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); - systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(metadataStub.withFace); - personMock.getDistinctNames.mockResolvedValue([]); - personMock.createAll.mockResolvedValue([personStub.withName.id]); - personMock.update.mockResolvedValue(personStub.withName); + mocks.person.getDistinctNames.mockResolvedValue([]); + mocks.person.createAll.mockResolvedValue([personStub.withName.id]); + mocks.person.update.mockResolvedValue(personStub.withName); await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id], { faces: { person: false } }); - expect(personMock.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); - expect(personMock.createAll).toHaveBeenCalledWith([expect.objectContaining({ name: personStub.withName.name })]); - expect(personMock.refreshFaces).toHaveBeenCalledWith( + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id], { faces: { person: false } }); + expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); + expect(mocks.person.createAll).toHaveBeenCalledWith([ + expect.objectContaining({ name: personStub.withName.name }), + ]); + expect(mocks.person.refreshFaces).toHaveBeenCalledWith( [ { id: 'random-uuid', @@ -1084,10 +1007,10 @@ describe(MetadataService.name, () => { ], [], ); - expect(personMock.updateAll).toHaveBeenCalledWith([ + expect(mocks.person.updateAll).toHaveBeenCalledWith([ { id: 'random-uuid', ownerId: 'admin-id', faceAssetId: 'random-uuid' }, ]); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personStub.withName.id }, @@ -1096,17 +1019,17 @@ describe(MetadataService.name, () => { }); it('should assign metadata face tags to existing persons', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); - systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); + mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(metadataStub.withFace); - personMock.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]); - personMock.createAll.mockResolvedValue([]); - personMock.update.mockResolvedValue(personStub.withName); + mocks.person.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]); + mocks.person.createAll.mockResolvedValue([]); + mocks.person.update.mockResolvedValue(personStub.withName); await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id], { faces: { person: false } }); - expect(personMock.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); - expect(personMock.createAll).not.toHaveBeenCalled(); - expect(personMock.refreshFaces).toHaveBeenCalledWith( + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id], { faces: { person: false } }); + expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); + expect(mocks.person.createAll).not.toHaveBeenCalled(); + expect(mocks.person.refreshFaces).toHaveBeenCalledWith( [ { id: 'random-uuid', @@ -1123,16 +1046,16 @@ describe(MetadataService.name, () => { ], [], ); - expect(personMock.updateAll).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalledWith(); + expect(mocks.person.updateAll).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalledWith(); }); it('should handle invalid modify date', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ ModifyDate: '00:00:00.000' }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ modifyDate: expect.any(Date), }), @@ -1140,11 +1063,11 @@ describe(MetadataService.name, () => { }); it('should handle invalid rating value', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Rating: 6 }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ rating: null, }), @@ -1152,27 +1075,157 @@ describe(MetadataService.name, () => { }); it('should handle valid rating value', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mockReadTags({ Rating: 5 }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ rating: 5, }), ); }); + + it('should handle valid negative rating value', async () => { + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mockReadTags({ Rating: -1 }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + rating: -1, + }), + ); + }); + + it('should handle livePhotoCID not set', async () => { + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + + await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); + + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.findLivePhotoMatch).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalledWith(expect.objectContaining({ isVisible: false })); + expect(mocks.album.removeAsset).not.toHaveBeenCalled(); + }); + + it('should handle not finding a match', async () => { + mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); + mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + mockReadTags({ ContentIdentifier: 'CID' }); + + await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id })).resolves.toBe( + JobStatus.SUCCESS, + ); + + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { + faces: { person: false }, + }); + expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({ + livePhotoCID: 'CID', + ownerId: assetStub.livePhotoMotionAsset.ownerId, + otherAssetId: assetStub.livePhotoMotionAsset.id, + type: AssetType.IMAGE, + }); + expect(mocks.asset.update).not.toHaveBeenCalledWith(expect.objectContaining({ isVisible: false })); + expect(mocks.album.removeAsset).not.toHaveBeenCalled(); + }); + + it('should link photo and video', async () => { + mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]); + mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); + mockReadTags({ ContentIdentifier: 'CID' }); + + await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( + JobStatus.SUCCESS, + ); + + expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id], { + faces: { person: false }, + }); + expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({ + livePhotoCID: 'CID', + ownerId: assetStub.livePhotoStillAsset.ownerId, + otherAssetId: assetStub.livePhotoStillAsset.id, + type: AssetType.VIDEO, + }); + expect(mocks.asset.update).toHaveBeenCalledWith({ + id: assetStub.livePhotoStillAsset.id, + livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + }); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false }); + expect(mocks.album.removeAsset).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id); + }); + + it('should notify clients on live photo link', async () => { + mocks.asset.getByIds.mockResolvedValue([ + { + ...assetStub.livePhotoStillAsset, + exifInfo: { livePhotoCID: assetStub.livePhotoMotionAsset.id } as ExifEntity, + }, + ]); + mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); + mockReadTags({ ContentIdentifier: 'CID' }); + + await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( + JobStatus.SUCCESS, + ); + + expect(mocks.event.emit).toHaveBeenCalledWith('asset.hide', { + userId: assetStub.livePhotoMotionAsset.ownerId, + assetId: assetStub.livePhotoMotionAsset.id, + }); + }); + + it('should search by libraryId', async () => { + mocks.asset.getByIds.mockResolvedValue([ + { + ...assetStub.livePhotoStillAsset, + libraryId: 'library-id', + }, + ]); + mockReadTags({ ContentIdentifier: 'CID' }); + + await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( + JobStatus.SUCCESS, + ); + + expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({ + ownerId: 'user-id', + otherAssetId: 'live-photo-still-asset', + livePhotoCID: 'CID', + libraryId: 'library-id', + type: 'VIDEO', + }); + }); + + it.each([ + { Make: '1', Model: '2', Device: { Manufacturer: '3', ModelName: '4' }, AndroidMake: '4', AndroidModel: '5' }, + { Device: { Manufacturer: '1', ModelName: '2' }, AndroidMake: '3', AndroidModel: '4' }, + { AndroidMake: '1', AndroidModel: '2' }, + ])('should read camera make and model correct place %s', async (metaData) => { + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mockReadTags(metaData); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + make: '1', + model: '2', + }), + ); + }); }); describe('handleQueueSidecar', () => { it('should queue assets with sidecar files', async () => { - assetMock.getAll.mockResolvedValue({ items: [assetStub.sidecar], hasNextPage: false }); + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.sidecar], hasNextPage: false }); await sut.handleQueueSidecar({ force: true }); - expect(assetMock.getAll).toHaveBeenCalledWith({ take: 1000, skip: 0 }); - expect(assetMock.getWithout).not.toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).toHaveBeenCalledWith({ take: 1000, skip: 0 }); + expect(mocks.asset.getWithout).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.SIDECAR_SYNC, data: { id: assetStub.sidecar.id }, @@ -1181,13 +1234,13 @@ describe(MetadataService.name, () => { }); it('should queue assets without sidecar files', async () => { - assetMock.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); await sut.handleQueueSidecar({ force: false }); - expect(assetMock.getWithout).toHaveBeenCalledWith({ take: 1000, skip: 0 }, WithoutProperty.SIDECAR); - expect(assetMock.getAll).not.toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getWithout).toHaveBeenCalledWith({ take: 1000, skip: 0 }, WithoutProperty.SIDECAR); + expect(mocks.asset.getAll).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.SIDECAR_DISCOVERY, data: { id: assetStub.image.id }, @@ -1198,71 +1251,77 @@ describe(MetadataService.name, () => { describe('handleSidecarSync', () => { it('should do nothing if asset could not be found', async () => { - assetMock.getByIds.mockResolvedValue([]); + mocks.asset.getByIds.mockResolvedValue([]); await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should do nothing if asset has no sidecar path', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should set sidecar path if exists (sidecar named photo.ext.xmp)', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); - storageMock.checkFileExists.mockResolvedValue(true); + mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.storage.checkFileExists.mockResolvedValue(true); await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SUCCESS); - expect(storageMock.checkFileExists).toHaveBeenCalledWith(`${assetStub.sidecar.originalPath}.xmp`, constants.R_OK); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.storage.checkFileExists).toHaveBeenCalledWith( + `${assetStub.sidecar.originalPath}.xmp`, + constants.R_OK, + ); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.sidecar.id, sidecarPath: assetStub.sidecar.sidecarPath, }); }); it('should set sidecar path if exists (sidecar named photo.xmp)', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.sidecarWithoutExt]); - storageMock.checkFileExists.mockResolvedValueOnce(false); - storageMock.checkFileExists.mockResolvedValueOnce(true); + mocks.asset.getByIds.mockResolvedValue([assetStub.sidecarWithoutExt]); + mocks.storage.checkFileExists.mockResolvedValueOnce(false); + mocks.storage.checkFileExists.mockResolvedValueOnce(true); await expect(sut.handleSidecarSync({ id: assetStub.sidecarWithoutExt.id })).resolves.toBe(JobStatus.SUCCESS); - expect(storageMock.checkFileExists).toHaveBeenNthCalledWith( + expect(mocks.storage.checkFileExists).toHaveBeenNthCalledWith( 2, assetStub.sidecarWithoutExt.sidecarPath, constants.R_OK, ); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.sidecarWithoutExt.id, sidecarPath: assetStub.sidecarWithoutExt.sidecarPath, }); }); it('should set sidecar path if exists (two sidecars named photo.ext.xmp and photo.xmp, should pick photo.ext.xmp)', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); - storageMock.checkFileExists.mockResolvedValueOnce(true); - storageMock.checkFileExists.mockResolvedValueOnce(true); + mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.storage.checkFileExists.mockResolvedValueOnce(true); + mocks.storage.checkFileExists.mockResolvedValueOnce(true); await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SUCCESS); - expect(storageMock.checkFileExists).toHaveBeenNthCalledWith(1, assetStub.sidecar.sidecarPath, constants.R_OK); - expect(storageMock.checkFileExists).toHaveBeenNthCalledWith( + expect(mocks.storage.checkFileExists).toHaveBeenNthCalledWith(1, assetStub.sidecar.sidecarPath, constants.R_OK); + expect(mocks.storage.checkFileExists).toHaveBeenNthCalledWith( 2, assetStub.sidecarWithoutExt.sidecarPath, constants.R_OK, ); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.sidecar.id, sidecarPath: assetStub.sidecar.sidecarPath, }); }); it('should unset sidecar path if file does not exist anymore', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); - storageMock.checkFileExists.mockResolvedValue(false); + mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.storage.checkFileExists.mockResolvedValue(false); await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SUCCESS); - expect(storageMock.checkFileExists).toHaveBeenCalledWith(`${assetStub.sidecar.originalPath}.xmp`, constants.R_OK); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.storage.checkFileExists).toHaveBeenCalledWith( + `${assetStub.sidecar.originalPath}.xmp`, + constants.R_OK, + ); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.sidecar.id, sidecarPath: null, }); @@ -1271,41 +1330,41 @@ describe(MetadataService.name, () => { describe('handleSidecarDiscovery', () => { it('should skip hidden assets', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); await sut.handleSidecarDiscovery({ id: assetStub.livePhotoMotionAsset.id }); - expect(storageMock.checkFileExists).not.toHaveBeenCalled(); + expect(mocks.storage.checkFileExists).not.toHaveBeenCalled(); }); it('should skip assets with a sidecar path', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); await sut.handleSidecarDiscovery({ id: assetStub.sidecar.id }); - expect(storageMock.checkFileExists).not.toHaveBeenCalled(); + expect(mocks.storage.checkFileExists).not.toHaveBeenCalled(); }); it('should do nothing when a sidecar is not found ', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - storageMock.checkFileExists.mockResolvedValue(false); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.storage.checkFileExists.mockResolvedValue(false); await sut.handleSidecarDiscovery({ id: assetStub.image.id }); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should update a image asset when a sidecar is found', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - storageMock.checkFileExists.mockResolvedValue(true); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.storage.checkFileExists.mockResolvedValue(true); await sut.handleSidecarDiscovery({ id: assetStub.image.id }); - expect(storageMock.checkFileExists).toHaveBeenCalledWith('/original/path.jpg.xmp', constants.R_OK); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.storage.checkFileExists).toHaveBeenCalledWith('/original/path.jpg.xmp', constants.R_OK); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, sidecarPath: '/original/path.jpg.xmp', }); }); it('should update a video asset when a sidecar is found', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.video]); - storageMock.checkFileExists.mockResolvedValue(true); + mocks.asset.getByIds.mockResolvedValue([assetStub.video]); + mocks.storage.checkFileExists.mockResolvedValue(true); await sut.handleSidecarDiscovery({ id: assetStub.video.id }); - expect(storageMock.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.R_OK); - expect(assetMock.update).toHaveBeenCalledWith({ + expect(mocks.storage.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.R_OK); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, sidecarPath: '/original/path.ext.xmp', }); @@ -1314,15 +1373,15 @@ describe(MetadataService.name, () => { describe('handleSidecarWrite', () => { it('should skip assets that do not exist anymore', async () => { - assetMock.getByIds.mockResolvedValue([]); + mocks.asset.getByIds.mockResolvedValue([]); await expect(sut.handleSidecarWrite({ id: 'asset-123' })).resolves.toBe(JobStatus.FAILED); - expect(metadataMock.writeTags).not.toHaveBeenCalled(); + expect(mocks.metadata.writeTags).not.toHaveBeenCalled(); }); it('should skip jobs with not metadata', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); await expect(sut.handleSidecarWrite({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SKIPPED); - expect(metadataMock.writeTags).not.toHaveBeenCalled(); + expect(mocks.metadata.writeTags).not.toHaveBeenCalled(); }); it('should write tags', async () => { @@ -1330,7 +1389,7 @@ describe(MetadataService.name, () => { const gps = 12; const date = '2023-11-22T04:56:12.196Z'; - assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); await expect( sut.handleSidecarWrite({ id: assetStub.sidecar.id, @@ -1340,7 +1399,7 @@ describe(MetadataService.name, () => { dateTimeOriginal: date, }), ).resolves.toBe(JobStatus.SUCCESS); - expect(metadataMock.writeTags).toHaveBeenCalledWith(assetStub.sidecar.sidecarPath, { + expect(mocks.metadata.writeTags).toHaveBeenCalledWith(assetStub.sidecar.sidecarPath, { Description: description, ImageDescription: description, DateTimeOriginal: date, diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index d5b7e6e4e4..4bf58a57fa 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -6,21 +6,29 @@ import _ from 'lodash'; import { Duration } from 'luxon'; import { constants } from 'node:fs/promises'; import path from 'node:path'; -import { SystemConfig } from 'src/config'; +import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { Exif } from 'src/db'; import { OnEvent, OnJob } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { PersonEntity } from 'src/entities/person.entity'; -import { AssetType, ExifOrientation, ImmichWorker, SourceType } from 'src/enum'; -import { WithoutProperty } from 'src/interfaces/asset.interface'; -import { DatabaseLock } from 'src/interfaces/database.interface'; -import { ArgOf } from 'src/interfaces/event.interface'; -import { JobName, JobOf, JOBS_ASSET_PAGINATION_SIZE, JobStatus, QueueName } from 'src/interfaces/job.interface'; +import { + AssetType, + DatabaseLock, + ExifOrientation, + ImmichWorker, + JobName, + JobStatus, + QueueName, + SourceType, +} from 'src/enum'; +import { WithoutProperty } from 'src/repositories/asset.repository'; +import { ArgOf } from 'src/repositories/event.repository'; import { ReverseGeocodeResult } from 'src/repositories/map.repository'; import { ImmichTags } from 'src/repositories/metadata.repository'; import { BaseService } from 'src/services/base.service'; +import { JobOf } from 'src/types'; import { isFaceImportEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; import { upsertTags } from 'src/utils/tag'; @@ -67,6 +75,8 @@ const validateRange = (value: number | undefined, min: number, max: number): Non return val; }; +type ImmichTagsWithFaces = ImmichTags & { RegionInfo: NonNullable }; + @Injectable() export class MetadataService extends BaseService { @OnEvent({ name: 'app.bootstrap', workers: [ImmichWorker.MICROSERVICES] }) @@ -80,6 +90,16 @@ export class MetadataService extends BaseService { await this.metadataRepository.teardown(); } + @OnEvent({ name: 'config.init', workers: [ImmichWorker.MICROSERVICES] }) + onConfigInit({ newConfig }: ArgOf<'config.init'>) { + this.metadataRepository.setMaxConcurrency(newConfig.job.metadataExtraction.concurrency); + } + + @OnEvent({ name: 'config.update', workers: [ImmichWorker.MICROSERVICES], server: true }) + onConfigUpdate({ newConfig }: ArgOf<'config.update'>) { + this.metadataRepository.setMaxConcurrency(newConfig.job.metadataExtraction.concurrency); + } + private async init() { this.logger.log('Initializing metadata service'); @@ -94,21 +114,14 @@ export class MetadataService extends BaseService { } } - @OnJob({ name: JobName.LINK_LIVE_PHOTOS, queue: QueueName.METADATA_EXTRACTION }) - async handleLivePhotoLinking(job: JobOf): Promise { - const { id } = job; - const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true }); - if (!asset?.exifInfo) { - return JobStatus.FAILED; - } - - if (!asset.exifInfo.livePhotoCID) { - return JobStatus.SKIPPED; + private async linkLivePhotos(asset: AssetEntity, exifInfo: Insertable): Promise { + if (!exifInfo.livePhotoCID) { + return; } const otherType = asset.type === AssetType.VIDEO ? AssetType.IMAGE : AssetType.VIDEO; const match = await this.assetRepository.findLivePhotoMatch({ - livePhotoCID: asset.exifInfo.livePhotoCID, + livePhotoCID: exifInfo.livePhotoCID, ownerId: asset.ownerId, libraryId: asset.libraryId, otherAssetId: asset.id, @@ -116,18 +129,17 @@ export class MetadataService extends BaseService { }); if (!match) { - return JobStatus.SKIPPED; + return; } const [photoAsset, motionAsset] = asset.type === AssetType.IMAGE ? [asset, match] : [match, asset]; - - await this.assetRepository.update({ id: photoAsset.id, livePhotoVideoId: motionAsset.id }); - await this.assetRepository.update({ id: motionAsset.id, isVisible: false }); - await this.albumRepository.removeAsset(motionAsset.id); + await Promise.all([ + this.assetRepository.update({ id: photoAsset.id, livePhotoVideoId: motionAsset.id }), + this.assetRepository.update({ id: motionAsset.id, isVisible: false }), + this.albumRepository.removeAsset(motionAsset.id), + ]); await this.eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId: motionAsset.ownerId }); - - return JobStatus.SUCCESS; } @OnJob({ name: JobName.QUEUE_METADATA_EXTRACTION, queue: QueueName.METADATA_EXTRACTION }) @@ -149,23 +161,40 @@ export class MetadataService extends BaseService { } @OnJob({ name: JobName.METADATA_EXTRACTION, queue: QueueName.METADATA_EXTRACTION }) - async handleMetadataExtraction({ id }: JobOf): Promise { - const { metadata, reverseGeocoding } = await this.getConfig({ withCache: true }); - const [asset] = await this.assetRepository.getByIds([id], { faces: { person: false } }); + async handleMetadataExtraction(data: JobOf): Promise { + const [{ metadata, reverseGeocoding }, [asset]] = await Promise.all([ + this.getConfig({ withCache: true }), + this.assetRepository.getByIds([data.id], { faces: { person: false } }), + ]); + if (!asset) { return JobStatus.FAILED; } - const stats = await this.storageRepository.stat(asset.originalPath); - const exifTags = await this.getExifTags(asset); + if (!exifTags.FileCreateDate || !exifTags.FileModifyDate || exifTags.FileSize === undefined) { + this.logger.warn(`Missing file creation or modification date for asset ${asset.id}: ${asset.originalPath}`); + const stat = await this.storageRepository.stat(asset.originalPath); + exifTags.FileCreateDate = stat.ctime.toISOString(); + exifTags.FileModifyDate = stat.mtime.toISOString(); + exifTags.FileSize = stat.size.toString(); + } this.logger.verbose('Exif Tags', exifTags); const { dateTimeOriginal, localDateTime, timeZone, modifyDate } = this.getDates(asset, exifTags); - const { latitude, longitude, country, state, city } = await this.getGeo(exifTags, reverseGeocoding); const { width, height } = this.getImageDimensions(exifTags); + let geo: ReverseGeocodeResult, latitude: number | null, longitude: number | null; + if (reverseGeocoding.enabled && this.hasGeo(exifTags)) { + latitude = exifTags.GPSLatitude; + longitude = exifTags.GPSLongitude; + geo = await this.mapRepository.reverseGeocode({ latitude, longitude }); + } else { + latitude = null; + longitude = null; + geo = { country: null, state: null, city: null }; + } const exifData: Insertable = { assetId: asset.id, @@ -178,12 +207,12 @@ export class MetadataService extends BaseService { // gps latitude, longitude, - country, - state, - city, + country: geo.country, + state: geo.state, + city: geo.city, // image/file - fileSizeInByte: stats.size, + fileSizeInByte: Number.parseInt(exifTags.FileSize!), exifImageHeight: validate(height), exifImageWidth: validate(width), orientation: validate(exifTags.Orientation)?.toString() ?? null, @@ -192,8 +221,8 @@ export class MetadataService extends BaseService { colorspace: exifTags.ColorSpace ?? null, // camera - make: exifTags.Make ?? null, - model: exifTags.Model ?? null, + make: exifTags.Make ?? exifTags?.Device?.Manufacturer ?? exifTags.AndroidMake ?? null, + model: exifTags.Model ?? exifTags?.Device?.ModelName ?? exifTags.AndroidModel ?? null, fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)), iso: validate(exifTags.ISO) as number, exposureTime: exifTags.ExposureTime ?? null, @@ -204,34 +233,40 @@ export class MetadataService extends BaseService { // comments description: String(exifTags.ImageDescription || exifTags.Description || '').trim(), profileDescription: exifTags.ProfileDescription || null, - rating: validateRange(exifTags.Rating, 0, 5), + rating: validateRange(exifTags.Rating, -1, 5), // grouping livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null, autoStackId: this.getAutoStackId(exifTags), }; - await this.applyTagList(asset, exifTags); - await this.applyMotionPhotos(asset, exifTags); + const promises: Promise[] = [ + this.assetRepository.upsertExif(exifData), + this.assetRepository.update({ + id: asset.id, + duration: exifTags.Duration?.toString() ?? null, + localDateTime, + fileCreatedAt: exifData.dateTimeOriginal ?? undefined, + fileModifiedAt: exifData.modifyDate ?? undefined, + }), + this.applyTagList(asset, exifTags), + ]; - await this.assetRepository.upsertExif(exifData); - - await this.assetRepository.update({ - id: asset.id, - duration: exifTags.Duration?.toString() ?? null, - localDateTime, - fileCreatedAt: exifData.dateTimeOriginal ?? undefined, - }); - - await this.assetRepository.upsertJobStatus({ - assetId: asset.id, - metadataExtractedAt: new Date(), - }); - - if (isFaceImportEnabled(metadata)) { - await this.applyTaggedFaces(asset, exifTags); + if (this.isMotionPhoto(asset, exifTags)) { + promises.push(this.applyMotionPhotos(asset, exifTags, exifData.fileSizeInByte!)); } + if (isFaceImportEnabled(metadata) && this.hasTaggedFaces(exifTags)) { + promises.push(this.applyTaggedFaces(asset, exifTags)); + } + + await Promise.all(promises); + if (exifData.livePhotoCID) { + await this.linkLivePhotos(asset, exifData); + } + + await this.assetRepository.upsertJobStatus({ assetId: asset.id, metadataExtractedAt: new Date() }); + return JobStatus.SUCCESS; } @@ -326,57 +361,78 @@ export class MetadataService extends BaseService { return { width, height }; } - private async getExifTags(asset: AssetEntity): Promise { - const mediaTags = await this.metadataRepository.readTags(asset.originalPath); - const sidecarTags = asset.sidecarPath ? await this.metadataRepository.readTags(asset.sidecarPath) : {}; - const videoTags = asset.type === AssetType.VIDEO ? await this.getVideoTags(asset.originalPath) : {}; + private getExifTags(asset: AssetEntity): Promise { + if (!asset.sidecarPath && asset.type === AssetType.IMAGE) { + return this.metadataRepository.readTags(asset.originalPath); + } + + return this.mergeExifTags(asset); + } + + private async mergeExifTags(asset: AssetEntity): Promise { + const [mediaTags, sidecarTags, videoTags] = await Promise.all([ + this.metadataRepository.readTags(asset.originalPath), + asset.sidecarPath ? this.metadataRepository.readTags(asset.sidecarPath) : null, + asset.type === AssetType.VIDEO ? this.getVideoTags(asset.originalPath) : null, + ]); // prefer dates from sidecar tags - const sidecarDate = firstDateTime(sidecarTags as Tags, EXIF_DATE_TAGS); - if (sidecarDate) { - for (const tag of EXIF_DATE_TAGS) { - delete mediaTags[tag]; + if (sidecarTags) { + const sidecarDate = firstDateTime(sidecarTags as Tags, EXIF_DATE_TAGS); + if (sidecarDate) { + for (const tag of EXIF_DATE_TAGS) { + delete mediaTags[tag]; + } } } // prefer duration from video tags delete mediaTags.Duration; - delete sidecarTags.Duration; + delete sidecarTags?.Duration; return { ...mediaTags, ...videoTags, ...sidecarTags }; } - private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) { - const tags: string[] = []; + private getTagList(exifTags: ImmichTags): string[] { + let tags: string[]; if (exifTags.TagsList) { - tags.push(...exifTags.TagsList.map(String)); + tags = exifTags.TagsList.map(String); } else if (exifTags.HierarchicalSubject) { - tags.push( - ...exifTags.HierarchicalSubject.map((tag) => - String(tag) - // convert | to / - .replaceAll('/', '') - .replaceAll('|', '/') - .replaceAll('', '|'), - ), + tags = exifTags.HierarchicalSubject.map((tag) => + // convert | to / + typeof tag === 'number' + ? String(tag) + : tag + .split('|') + .map((tag) => tag.replaceAll('/', '|')) + .join('/'), ); } else if (exifTags.Keywords) { let keywords = exifTags.Keywords; if (!Array.isArray(keywords)) { keywords = [keywords]; } - tags.push(...keywords.map(String)); + tags = keywords.map(String); + } else { + tags = []; } - - const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags }); - await this.tagRepository.upsertAssetTags({ assetId: asset.id, tagIds: results.map((tag) => tag.id) }); + return tags; } - private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags) { - if (asset.type !== AssetType.IMAGE) { - return; - } + private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) { + const tags = this.getTagList(exifTags); + const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags }); + await this.tagRepository.replaceAssetTags( + asset.id, + results.map((tag) => tag.id), + ); + } + private isMotionPhoto(asset: AssetEntity, tags: ImmichTags): boolean { + return asset.type === AssetType.IMAGE && !!(tags.MotionPhoto || tags.MicroVideo); + } + + private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags, fileSize: number) { const isMotionPhoto = tags.MotionPhoto; const isMicroVideo = tags.MicroVideo; const videoOffset = tags.MicroVideoOffset; @@ -391,7 +447,7 @@ export class MetadataService extends BaseService { if (isMotionPhoto && directory) { for (const entry of directory) { - if (entry?.Item?.Semantic == 'MotionPhoto') { + if (entry?.Item?.Semantic === 'MotionPhoto') { length = entry.Item.Length ?? 0; padding = entry.Item.Padding ?? 0; break; @@ -407,11 +463,10 @@ export class MetadataService extends BaseService { return; } - this.logger.debug(`Starting motion photo video extraction (${asset.id})`); + this.logger.debug(`Starting motion photo video extraction for asset ${asset.id}: ${asset.originalPath}`); try { - const stat = await this.storageRepository.stat(asset.originalPath); - const position = stat.size - length - padding; + const position = fileSize - length - padding; let video: Buffer; // Samsung MotionPhoto video extraction // HEIC-encoded @@ -438,11 +493,10 @@ export class MetadataService extends BaseService { checksum, }); if (motionAsset) { - this.logger.debug( - `Asset ${asset.id}'s motion photo video with checksum ${checksum.toString( - 'base64', - )} already exists in the repository`, - ); + this.logger.debugFn(() => { + const base64Checksum = checksum.toString('base64'); + return `Motion asset with checksum ${base64Checksum} already exists for asset ${asset.id}: ${asset.originalPath}`; + }); // Hide the motion photo video asset if it's not already hidden to prepare for linking if (motionAsset.isVisible) { @@ -498,12 +552,21 @@ export class MetadataService extends BaseService { await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: motionAsset.id } }); } - this.logger.debug(`Finished motion photo video extraction (${asset.id})`); + this.logger.debug(`Finished motion photo video extraction for asset ${asset.id}: ${asset.originalPath}`); } catch (error: Error | any) { - this.logger.error(`Failed to extract live photo ${asset.originalPath}: ${error}`, error?.stack); + this.logger.error( + `Failed to extract motion video for ${asset.id}: ${asset.originalPath}: ${error}`, + error?.stack, + ); } } + private hasTaggedFaces(tags: ImmichTags): tags is ImmichTagsWithFaces { + return ( + tags.RegionInfo !== undefined && tags.RegionInfo.AppliedToDimensions && tags.RegionInfo.RegionList.length > 0 + ); + } + private async applyTaggedFaces(asset: AssetEntity, tags: ImmichTags) { if (!tags.RegionInfo?.AppliedToDimensions || tags.RegionInfo.RegionList.length === 0) { return; @@ -545,7 +608,7 @@ export class MetadataService extends BaseService { } if (missing.length > 0) { - this.logger.debug(`Creating missing persons: ${missing.map((p) => `${p.name}/${p.id}`)}`); + this.logger.debugFn(() => `Creating missing persons: ${missing.map((p) => `${p.name}/${p.id}`)}`); const newPersonIds = await this.personRepository.createAll(missing); const jobs = newPersonIds.map((id) => ({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } }) as const); await this.jobRepository.queueAll(jobs); @@ -553,11 +616,13 @@ export class MetadataService extends BaseService { const facesToRemove = asset.faces.filter((face) => face.sourceType === SourceType.EXIF).map((face) => face.id); if (facesToRemove.length > 0) { - this.logger.debug(`Removing ${facesToRemove.length} faces for asset ${asset.id}`); + this.logger.debug(`Removing ${facesToRemove.length} faces for asset ${asset.id}: ${asset.originalPath}`); } if (facesToAdd.length > 0) { - this.logger.debug(`Creating ${facesToAdd.length} faces from metadata for asset ${asset.id}`); + this.logger.debug( + `Creating ${facesToAdd.length} faces from metadata for asset ${asset.id}: ${asset.originalPath}`, + ); } if (facesToRemove.length > 0 || facesToAdd.length > 0) { @@ -571,7 +636,7 @@ export class MetadataService extends BaseService { private getDates(asset: AssetEntity, exifTags: ImmichTags) { const dateTime = firstDateTime(exifTags as Maybe, EXIF_DATE_TAGS); - this.logger.verbose(`Asset ${asset.id} date time is ${dateTime}`); + this.logger.verbose(`Date and time is ${dateTime} for asset ${asset.id}: ${asset.originalPath}`); // timezone let timeZone = exifTags.tz ?? null; @@ -582,28 +647,29 @@ export class MetadataService extends BaseService { } if (timeZone) { - this.logger.verbose(`Asset ${asset.id} timezone is ${timeZone} (via ${exifTags.tzSource})`); + this.logger.verbose( + `Found timezone ${timeZone} via ${exifTags.tzSource} for asset ${asset.id}: ${asset.originalPath}`, + ); } else { - this.logger.warn(`Asset ${asset.id} has no time zone information`); + this.logger.debug(`No timezone information found for asset ${asset.id}: ${asset.originalPath}`); } + const modifyDate = this.toDate(exifTags.FileModifyDate!); let dateTimeOriginal = dateTime?.toDate(); let localDateTime = dateTime?.toDateTime().setZone('UTC', { keepLocalTime: true }).toJSDate(); if (!localDateTime || !dateTimeOriginal) { + const fileCreatedAt = this.toDate(exifTags.FileCreateDate!); + const earliestDate = this.earliestDate(fileCreatedAt, modifyDate); this.logger.debug( - `No valid date found in exif tags from asset ${asset.id}, falling back to earliest timestamp between file creation and file modification`, + `No exif date time found, falling back on ${earliestDate.toISOString()}, earliest of file creation and modification for assset ${asset.id}: ${asset.originalPath}`, ); - const earliestDate = this.earliestDate(asset.fileModifiedAt, asset.fileCreatedAt); dateTimeOriginal = earliestDate; localDateTime = earliestDate; } - this.logger.verbose(`Asset ${asset.id} has a local time of ${localDateTime.toISOString()}`); - - let modifyDate = asset.fileModifiedAt; - try { - modifyDate = (exifTags.ModifyDate as ExifDateTime)?.toDate() ?? modifyDate; - } catch {} + this.logger.verbose( + `Found local date time ${localDateTime.toISOString()} for asset ${asset.id}: ${asset.originalPath}`, + ); return { dateTimeOriginal, @@ -613,28 +679,20 @@ export class MetadataService extends BaseService { }; } + private toDate(date: string | ExifDateTime): Date { + return typeof date === 'string' ? new Date(date) : date.toDate(); + } + private earliestDate(a: Date, b: Date) { return new Date(Math.min(a.valueOf(), b.valueOf())); } - private async getGeo(tags: ImmichTags, reverseGeocoding: SystemConfig['reverseGeocoding']) { - let latitude = validate(tags.GPSLatitude); - let longitude = validate(tags.GPSLongitude); - - // TODO take ref into account - - if (latitude === 0 && longitude === 0) { - this.logger.warn('Latitude and longitude of 0, setting to null'); - latitude = null; - longitude = null; - } - - let result: ReverseGeocodeResult = { country: null, state: null, city: null }; - if (reverseGeocoding.enabled && longitude && latitude) { - result = await this.mapRepository.reverseGeocode({ latitude, longitude }); - } - - return { ...result, latitude, longitude }; + private hasGeo(tags: ImmichTags): tags is ImmichTags & { GPSLatitude: number; GPSLongitude: number } { + return ( + tags.GPSLatitude !== undefined && + tags.GPSLongitude !== undefined && + (tags.GPSLatitude !== 0 || tags.GPSLatitude !== 0) + ); } private getAutoStackId(tags: ImmichTags | null): string | null { @@ -736,6 +794,7 @@ export class MetadataService extends BaseService { } if (sidecarPath) { + this.logger.debug(`Detected sidecar at '${sidecarPath}' for asset ${asset.id}: ${asset.originalPath}`); await this.assetRepository.update({ id: asset.id, sidecarPath }); return JobStatus.SUCCESS; } @@ -744,9 +803,7 @@ export class MetadataService extends BaseService { return JobStatus.FAILED; } - this.logger.debug( - `Sidecar file was not found. Checked paths '${sidecarPathWithExt}' and '${sidecarPathWithoutExt}'. Removing sidecarPath for asset ${asset.id}`, - ); + this.logger.debug(`No sidecar found for asset ${asset.id}: ${asset.originalPath}`); await this.assetRepository.update({ id: asset.id, sidecarPath: null }); return JobStatus.SUCCESS; diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 671cae0774..0deb3805e5 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -3,21 +3,14 @@ import { defaults, SystemConfig } from 'src/config'; import { SystemConfigDto } from 'src/dtos/system-config.dto'; import { AlbumUserEntity } from 'src/entities/album-user.entity'; import { AssetFileEntity } from 'src/entities/asset-files.entity'; -import { AssetFileType, UserMetadataKey } from 'src/enum'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { IJobRepository, INotifyAlbumUpdateJob, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum'; import { EmailTemplate } from 'src/repositories/notification.repository'; import { NotificationService } from 'src/services/notification.service'; -import { INotificationRepository } from 'src/types'; +import { INotifyAlbumUpdateJob } from 'src/types'; import { albumStub } from 'test/fixtures/album.stub'; import { assetStub } from 'test/fixtures/asset.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; const configs = { smtpDisabled: Object.freeze({ @@ -58,18 +51,10 @@ const configs = { describe(NotificationService.name, () => { let sut: NotificationService; - - let albumMock: Mocked; - let assetMock: Mocked; - let eventMock: Mocked; - let jobMock: Mocked; - let notificationMock: Mocked; - let systemMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, albumMock, assetMock, eventMock, jobMock, notificationMock, systemMock, userMock } = - newTestService(NotificationService)); + ({ sut, mocks } = newTestService(NotificationService)); }); it('should work', () => { @@ -80,8 +65,8 @@ describe(NotificationService.name, () => { it('should emit client and server events', () => { const update = { oldConfig: defaults, newConfig: defaults }; expect(sut.onConfigUpdate(update)).toBeUndefined(); - expect(eventMock.clientBroadcast).toHaveBeenCalledWith('on_config_update'); - expect(eventMock.serverSend).toHaveBeenCalledWith('config.update', update); + expect(mocks.event.clientBroadcast).toHaveBeenCalledWith('on_config_update'); + expect(mocks.event.serverSend).toHaveBeenCalledWith('config.update', update); }); }); @@ -90,18 +75,18 @@ describe(NotificationService.name, () => { const oldConfig = configs.smtpDisabled; const newConfig = configs.smtpEnabled; - notificationMock.verifySmtp.mockResolvedValue(true); + mocks.notification.verifySmtp.mockResolvedValue(true); await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); - expect(notificationMock.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport); + expect(mocks.notification.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport); }); it('validates smtp config when transport changes', async () => { const oldConfig = configs.smtpEnabled; const newConfig = configs.smtpTransport; - notificationMock.verifySmtp.mockResolvedValue(true); + mocks.notification.verifySmtp.mockResolvedValue(true); await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); - expect(notificationMock.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport); + expect(mocks.notification.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport); }); it('skips smtp validation when there are no changes', async () => { @@ -109,7 +94,7 @@ describe(NotificationService.name, () => { const newConfig = { ...configs.smtpEnabled }; await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); - expect(notificationMock.verifySmtp).not.toHaveBeenCalled(); + expect(mocks.notification.verifySmtp).not.toHaveBeenCalled(); }); it('skips smtp validation with DTO when there are no changes', async () => { @@ -117,7 +102,7 @@ describe(NotificationService.name, () => { const newConfig = plainToInstance(SystemConfigDto, configs.smtpEnabled); await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); - expect(notificationMock.verifySmtp).not.toHaveBeenCalled(); + expect(mocks.notification.verifySmtp).not.toHaveBeenCalled(); }); it('skips smtp validation when smtp is disabled', async () => { @@ -125,14 +110,14 @@ describe(NotificationService.name, () => { const newConfig = { ...configs.smtpDisabled }; await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); - expect(notificationMock.verifySmtp).not.toHaveBeenCalled(); + expect(mocks.notification.verifySmtp).not.toHaveBeenCalled(); }); it('should fail if smtp configuration is invalid', async () => { const oldConfig = configs.smtpDisabled; const newConfig = configs.smtpEnabled; - notificationMock.verifySmtp.mockRejectedValue(new Error('Failed validating smtp')); + mocks.notification.verifySmtp.mockRejectedValue(new Error('Failed validating smtp')); await expect(sut.onConfigValidate({ oldConfig, newConfig })).rejects.toBeInstanceOf(Error); }); }); @@ -140,14 +125,14 @@ describe(NotificationService.name, () => { describe('onAssetHide', () => { it('should send connected clients an event', () => { sut.onAssetHide({ assetId: 'asset-id', userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_hidden', 'user-id', 'asset-id'); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_hidden', 'user-id', 'asset-id'); }); }); describe('onAssetShow', () => { it('should queue the generate thumbnail job', async () => { await sut.onAssetShow({ assetId: 'asset-id', userId: 'user-id' }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-id', notify: true }, }); @@ -157,12 +142,12 @@ describe(NotificationService.name, () => { describe('onUserSignupEvent', () => { it('skips when notify is false', async () => { await sut.onUserSignup({ id: '', notify: false }); - expect(jobMock.queue).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); }); it('should queue notify signup event if notify is true', async () => { await sut.onUserSignup({ id: '', notify: true }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.NOTIFY_SIGNUP, data: { id: '', tempPassword: undefined }, }); @@ -172,7 +157,7 @@ describe(NotificationService.name, () => { describe('onAlbumUpdateEvent', () => { it('should queue notify album update event', async () => { await sut.onAlbumUpdate({ id: 'album', recipientIds: ['42'] }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.NOTIFY_ALBUM_UPDATE, data: { id: 'album', recipientIds: ['42'], delay: 300_000 }, }); @@ -182,7 +167,7 @@ describe(NotificationService.name, () => { describe('onAlbumInviteEvent', () => { it('should queue notify album invite event', async () => { await sut.onAlbumInvite({ id: '', userId: '42' }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id: '', recipientId: '42' }, }); @@ -193,67 +178,67 @@ describe(NotificationService.name, () => { it('should send a on_session_delete client event', () => { vi.useFakeTimers(); sut.onSessionDelete({ sessionId: 'id' }); - expect(eventMock.clientSend).not.toHaveBeenCalled(); + expect(mocks.event.clientSend).not.toHaveBeenCalled(); vi.advanceTimersByTime(500); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_session_delete', 'id', 'id'); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_session_delete', 'id', 'id'); }); }); describe('onAssetTrash', () => { it('should send connected clients an event', () => { sut.onAssetTrash({ assetId: 'asset-id', userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_trash', 'user-id', ['asset-id']); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_trash', 'user-id', ['asset-id']); }); }); describe('onAssetDelete', () => { it('should send connected clients an event', () => { sut.onAssetDelete({ assetId: 'asset-id', userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_delete', 'user-id', 'asset-id'); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_delete', 'user-id', 'asset-id'); }); }); describe('onAssetsTrash', () => { it('should send connected clients an event', () => { sut.onAssetsTrash({ assetIds: ['asset-id'], userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_trash', 'user-id', ['asset-id']); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_trash', 'user-id', ['asset-id']); }); }); describe('onAssetsRestore', () => { it('should send connected clients an event', () => { sut.onAssetsRestore({ assetIds: ['asset-id'], userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_restore', 'user-id', ['asset-id']); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_restore', 'user-id', ['asset-id']); }); }); describe('onStackCreate', () => { it('should send connected clients an event', () => { sut.onStackCreate({ stackId: 'stack-id', userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); }); }); describe('onStackUpdate', () => { it('should send connected clients an event', () => { sut.onStackUpdate({ stackId: 'stack-id', userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); }); }); describe('onStackDelete', () => { it('should send connected clients an event', () => { sut.onStackDelete({ stackId: 'stack-id', userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); }); }); describe('onStacksDelete', () => { it('should send connected clients an event', () => { sut.onStacksDelete({ stackIds: ['stack-id'], userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); }); }); @@ -263,8 +248,8 @@ describe(NotificationService.name, () => { }); it('should throw error if smtp validation fails', async () => { - userMock.get.mockResolvedValue(userStub.admin); - notificationMock.verifySmtp.mockRejectedValue(''); + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.notification.verifySmtp.mockRejectedValue(''); await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).rejects.toThrow( 'Failed to verify SMTP configuration', @@ -272,16 +257,17 @@ describe(NotificationService.name, () => { }); it('should send email to default domain', async () => { - userMock.get.mockResolvedValue(userStub.admin); - notificationMock.verifySmtp.mockResolvedValue(true); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.notification.verifySmtp.mockResolvedValue(true); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.notification.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).resolves.not.toThrow(); - expect(notificationMock.renderEmail).toHaveBeenCalledWith({ + expect(mocks.notification.renderEmail).toHaveBeenCalledWith({ template: EmailTemplate.TEST_EMAIL, data: { baseUrl: 'http://localhost:2283', displayName: userStub.admin.name }, }); - expect(notificationMock.sendEmail).toHaveBeenCalledWith( + expect(mocks.notification.sendEmail).toHaveBeenCalledWith( expect.objectContaining({ subject: 'Test email from Immich', smtp: configs.smtpTransport.notifications.smtp.transport, @@ -290,17 +276,18 @@ describe(NotificationService.name, () => { }); it('should send email to external domain', async () => { - userMock.get.mockResolvedValue(userStub.admin); - notificationMock.verifySmtp.mockResolvedValue(true); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); - systemMock.get.mockResolvedValue({ server: { externalDomain: 'https://demo.immich.app' } }); + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.notification.verifySmtp.mockResolvedValue(true); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.systemMetadata.get.mockResolvedValue({ server: { externalDomain: 'https://demo.immich.app' } }); + mocks.notification.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).resolves.not.toThrow(); - expect(notificationMock.renderEmail).toHaveBeenCalledWith({ + expect(mocks.notification.renderEmail).toHaveBeenCalledWith({ template: EmailTemplate.TEST_EMAIL, data: { baseUrl: 'https://demo.immich.app', displayName: userStub.admin.name }, }); - expect(notificationMock.sendEmail).toHaveBeenCalledWith( + expect(mocks.notification.sendEmail).toHaveBeenCalledWith( expect.objectContaining({ subject: 'Test email from Immich', smtp: configs.smtpTransport.notifications.smtp.transport, @@ -309,18 +296,19 @@ describe(NotificationService.name, () => { }); it('should send email with replyTo', async () => { - userMock.get.mockResolvedValue(userStub.admin); - notificationMock.verifySmtp.mockResolvedValue(true); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.notification.verifySmtp.mockResolvedValue(true); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.notification.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); await expect( sut.sendTestEmail('', { ...configs.smtpTransport.notifications.smtp, replyTo: 'demo@immich.app' }), ).resolves.not.toThrow(); - expect(notificationMock.renderEmail).toHaveBeenCalledWith({ + expect(mocks.notification.renderEmail).toHaveBeenCalledWith({ template: EmailTemplate.TEST_EMAIL, data: { baseUrl: 'http://localhost:2283', displayName: userStub.admin.name }, }); - expect(notificationMock.sendEmail).toHaveBeenCalledWith( + expect(mocks.notification.sendEmail).toHaveBeenCalledWith( expect.objectContaining({ subject: 'Test email from Immich', smtp: configs.smtpTransport.notifications.smtp.transport, @@ -336,12 +324,12 @@ describe(NotificationService.name, () => { }); it('should be successful', async () => { - userMock.get.mockResolvedValue(userStub.admin); - systemMock.get.mockResolvedValue({ server: {} }); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.systemMetadata.get.mockResolvedValue({ server: {} }); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); await expect(sut.handleUserSignup({ id: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SEND_EMAIL, data: expect.objectContaining({ subject: 'Welcome to Immich' }), }); @@ -351,19 +339,19 @@ describe(NotificationService.name, () => { describe('handleAlbumInvite', () => { it('should skip if album could not be found', async () => { await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SKIPPED); - expect(userMock.get).not.toHaveBeenCalled(); + expect(mocks.user.get).not.toHaveBeenCalled(); }); it('should skip if recipient could not be found', async () => { - albumMock.getById.mockResolvedValue(albumStub.empty); + mocks.album.getById.mockResolvedValue(albumStub.empty); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SKIPPED); - expect(assetMock.getById).not.toHaveBeenCalled(); + expect(mocks.asset.getById).not.toHaveBeenCalled(); }); it('should skip if the recipient has email notifications disabled', async () => { - albumMock.getById.mockResolvedValue(albumStub.empty); - userMock.get.mockResolvedValue({ + mocks.album.getById.mockResolvedValue(albumStub.empty); + mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ { @@ -379,8 +367,8 @@ describe(NotificationService.name, () => { }); it('should skip if the recipient has email notifications for album invite disabled', async () => { - albumMock.getById.mockResolvedValue(albumStub.empty); - userMock.get.mockResolvedValue({ + mocks.album.getById.mockResolvedValue(albumStub.empty); + mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ { @@ -396,8 +384,8 @@ describe(NotificationService.name, () => { }); it('should send invite email', async () => { - albumMock.getById.mockResolvedValue(albumStub.empty); - userMock.get.mockResolvedValue({ + mocks.album.getById.mockResolvedValue(albumStub.empty); + mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ { @@ -408,19 +396,19 @@ describe(NotificationService.name, () => { }, ], }); - systemMock.get.mockResolvedValue({ server: {} }); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.systemMetadata.get.mockResolvedValue({ server: {} }); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SEND_EMAIL, data: expect.objectContaining({ subject: expect.stringContaining('You have been added to a shared album') }), }); }); it('should send invite email without album thumbnail if thumbnail asset does not exist', async () => { - albumMock.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); - userMock.get.mockResolvedValue({ + mocks.album.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); + mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ { @@ -431,14 +419,14 @@ describe(NotificationService.name, () => { }, ], }); - systemMock.get.mockResolvedValue({ server: {} }); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.systemMetadata.get.mockResolvedValue({ server: {} }); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { + expect(mocks.asset.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { files: true, }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SEND_EMAIL, data: expect.objectContaining({ subject: expect.stringContaining('You have been added to a shared album'), @@ -448,8 +436,8 @@ describe(NotificationService.name, () => { }); it('should send invite email with album thumbnail as jpeg', async () => { - albumMock.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); - userMock.get.mockResolvedValue({ + mocks.album.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); + mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ { @@ -460,18 +448,18 @@ describe(NotificationService.name, () => { }, ], }); - systemMock.get.mockResolvedValue({ server: {} }); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); - assetMock.getById.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ server: {} }); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.asset.getById.mockResolvedValue({ ...assetStub.image, files: [{ assetId: 'asset-id', type: AssetFileType.THUMBNAIL, path: 'path-to-thumb.jpg' } as AssetFileEntity], }); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { + expect(mocks.asset.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { files: true, }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SEND_EMAIL, data: expect.objectContaining({ subject: expect.stringContaining('You have been added to a shared album'), @@ -481,8 +469,8 @@ describe(NotificationService.name, () => { }); it('should send invite email with album thumbnail and arbitrary extension', async () => { - albumMock.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); - userMock.get.mockResolvedValue({ + mocks.album.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); + mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ { @@ -493,15 +481,15 @@ describe(NotificationService.name, () => { }, ], }); - systemMock.get.mockResolvedValue({ server: {} }); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.systemMetadata.get.mockResolvedValue({ server: {} }); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.asset.getById.mockResolvedValue(assetStub.image); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { + expect(mocks.asset.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { files: true, }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SEND_EMAIL, data: expect.objectContaining({ subject: expect.stringContaining('You have been added to a shared album'), @@ -514,35 +502,35 @@ describe(NotificationService.name, () => { describe('handleAlbumUpdate', () => { it('should skip if album could not be found', async () => { await expect(sut.handleAlbumUpdate({ id: '', recipientIds: ['1'] })).resolves.toBe(JobStatus.SKIPPED); - expect(userMock.get).not.toHaveBeenCalled(); + expect(mocks.user.get).not.toHaveBeenCalled(); }); it('should skip if owner could not be found', async () => { - albumMock.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); + mocks.album.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); await expect(sut.handleAlbumUpdate({ id: '', recipientIds: ['1'] })).resolves.toBe(JobStatus.SKIPPED); - expect(systemMock.get).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).not.toHaveBeenCalled(); }); it('should skip recipient that could not be looked up', async () => { - albumMock.getById.mockResolvedValue({ + mocks.album.getById.mockResolvedValue({ ...albumStub.emptyWithValidThumbnail, albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], }); - userMock.get.mockResolvedValueOnce(userStub.user1); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.user.get.mockResolvedValueOnce(userStub.user1); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); - expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); - expect(notificationMock.renderEmail).not.toHaveBeenCalled(); + expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); + expect(mocks.notification.renderEmail).not.toHaveBeenCalled(); }); it('should skip recipient with disabled email notifications', async () => { - albumMock.getById.mockResolvedValue({ + mocks.album.getById.mockResolvedValue({ ...albumStub.emptyWithValidThumbnail, albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], }); - userMock.get.mockResolvedValue({ + mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ { @@ -553,19 +541,19 @@ describe(NotificationService.name, () => { }, ], }); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); - expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); - expect(notificationMock.renderEmail).not.toHaveBeenCalled(); + expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); + expect(mocks.notification.renderEmail).not.toHaveBeenCalled(); }); it('should skip recipient with disabled email notifications for the album update event', async () => { - albumMock.getById.mockResolvedValue({ + mocks.album.getById.mockResolvedValue({ ...albumStub.emptyWithValidThumbnail, albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], }); - userMock.get.mockResolvedValue({ + mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ { @@ -576,31 +564,31 @@ describe(NotificationService.name, () => { }, ], }); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); - expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); - expect(notificationMock.renderEmail).not.toHaveBeenCalled(); + expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); + expect(mocks.notification.renderEmail).not.toHaveBeenCalled(); }); it('should send email', async () => { - albumMock.getById.mockResolvedValue({ + mocks.album.getById.mockResolvedValue({ ...albumStub.emptyWithValidThumbnail, albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], }); - userMock.get.mockResolvedValue(userStub.user1); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); - expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); - expect(notificationMock.renderEmail).toHaveBeenCalled(); - expect(jobMock.queue).toHaveBeenCalled(); + expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); + expect(mocks.notification.renderEmail).toHaveBeenCalled(); + expect(mocks.job.queue).toHaveBeenCalled(); }); it('should add new recipients for new images if job is already queued', async () => { - jobMock.removeJob.mockResolvedValue({ id: '1', recipientIds: ['2', '3', '4'] } as INotifyAlbumUpdateJob); + mocks.job.removeJob.mockResolvedValue({ id: '1', recipientIds: ['2', '3', '4'] } as INotifyAlbumUpdateJob); await sut.onAlbumUpdate({ id: '1', recipientIds: ['1', '2', '3'] } as INotifyAlbumUpdateJob); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.NOTIFY_ALBUM_UPDATE, data: { id: '1', @@ -613,26 +601,32 @@ describe(NotificationService.name, () => { describe('handleSendEmail', () => { it('should skip if smtp notifications are disabled', async () => { - systemMock.get.mockResolvedValue({ notifications: { smtp: { enabled: false } } }); + mocks.systemMetadata.get.mockResolvedValue({ notifications: { smtp: { enabled: false } } }); await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SKIPPED); }); it('should send mail successfully', async () => { - systemMock.get.mockResolvedValue({ notifications: { smtp: { enabled: true, from: 'test@immich.app' } } }); - notificationMock.sendEmail.mockResolvedValue({ messageId: '', response: '' }); + mocks.systemMetadata.get.mockResolvedValue({ + notifications: { smtp: { enabled: true, from: 'test@immich.app' } }, + }); + mocks.notification.sendEmail.mockResolvedValue({ messageId: '', response: '' }); await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(notificationMock.sendEmail).toHaveBeenCalledWith(expect.objectContaining({ replyTo: 'test@immich.app' })); + expect(mocks.notification.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ replyTo: 'test@immich.app' }), + ); }); it('should send mail with replyTo successfully', async () => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ notifications: { smtp: { enabled: true, from: 'test@immich.app', replyTo: 'demo@immich.app' } }, }); - notificationMock.sendEmail.mockResolvedValue({ messageId: '', response: '' }); + mocks.notification.sendEmail.mockResolvedValue({ messageId: '', response: '' }); await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(notificationMock.sendEmail).toHaveBeenCalledWith(expect.objectContaining({ replyTo: 'demo@immich.app' })); + expect(mocks.notification.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ replyTo: 'demo@immich.app' }), + ); }); }); }); diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 85f72443d4..bc6f6b8c2f 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -2,18 +2,11 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { OnEvent, OnJob } from 'src/decorators'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { AlbumEntity } from 'src/entities/album.entity'; -import { ArgOf } from 'src/interfaces/event.interface'; -import { - IEntityJob, - INotifyAlbumUpdateJob, - JobItem, - JobName, - JobOf, - JobStatus, - QueueName, -} from 'src/interfaces/job.interface'; -import { EmailImageAttachment, EmailTemplate } from 'src/repositories/notification.repository'; +import { JobName, JobStatus, QueueName } from 'src/enum'; +import { ArgOf } from 'src/repositories/event.repository'; +import { EmailTemplate } from 'src/repositories/notification.repository'; import { BaseService } from 'src/services/base.service'; +import { EmailImageAttachment, IEntityJob, INotifyAlbumUpdateJob, JobItem, JobOf } from 'src/types'; import { getAssetFiles } from 'src/utils/asset.util'; import { getFilenameExtension } from 'src/utils/file'; import { getExternalDomain } from 'src/utils/misc'; @@ -283,7 +276,7 @@ export class NotificationService extends BaseService { return JobStatus.SKIPPED; } - const { emailNotifications } = getPreferences(recipient); + const { emailNotifications } = getPreferences(recipient.email, recipient.metadata); if (!emailNotifications.enabled || !emailNotifications.albumInvite) { return JobStatus.SKIPPED; @@ -347,7 +340,7 @@ export class NotificationService extends BaseService { continue; } - const { emailNotifications } = getPreferences(user); + const { emailNotifications } = getPreferences(user.email, user.metadata); if (!emailNotifications.enabled || !emailNotifications.albumUpdate) { continue; diff --git a/server/src/services/partner.service.spec.ts b/server/src/services/partner.service.spec.ts index e7b7348e98..9c29afaeaa 100644 --- a/server/src/services/partner.service.spec.ts +++ b/server/src/services/partner.service.spec.ts @@ -1,20 +1,16 @@ import { BadRequestException } from '@nestjs/common'; -import { IPartnerRepository, PartnerDirection } from 'src/interfaces/partner.interface'; +import { PartnerDirection } from 'src/repositories/partner.repository'; import { PartnerService } from 'src/services/partner.service'; import { authStub } from 'test/fixtures/auth.stub'; import { partnerStub } from 'test/fixtures/partner.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(PartnerService.name, () => { let sut: PartnerService; - - let accessMock: IAccessRepositoryMock; - let partnerMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, partnerMock } = newTestService(PartnerService)); + ({ sut, mocks } = newTestService(PartnerService)); }); it('should work', () => { @@ -23,55 +19,55 @@ describe(PartnerService.name, () => { describe('search', () => { it("should return a list of partners with whom I've shared my library", async () => { - partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]); + mocks.partner.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]); await expect(sut.search(authStub.user1, { direction: PartnerDirection.SharedBy })).resolves.toBeDefined(); - expect(partnerMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id); + expect(mocks.partner.getAll).toHaveBeenCalledWith(authStub.user1.user.id); }); it('should return a list of partners who have shared their libraries with me', async () => { - partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]); + mocks.partner.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]); await expect(sut.search(authStub.user1, { direction: PartnerDirection.SharedWith })).resolves.toBeDefined(); - expect(partnerMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id); + expect(mocks.partner.getAll).toHaveBeenCalledWith(authStub.user1.user.id); }); }); describe('create', () => { it('should create a new partner', async () => { - partnerMock.get.mockResolvedValue(void 0); - partnerMock.create.mockResolvedValue(partnerStub.adminToUser1); + mocks.partner.get.mockResolvedValue(void 0); + mocks.partner.create.mockResolvedValue(partnerStub.adminToUser1); await expect(sut.create(authStub.admin, authStub.user1.user.id)).resolves.toBeDefined(); - expect(partnerMock.create).toHaveBeenCalledWith({ + expect(mocks.partner.create).toHaveBeenCalledWith({ sharedById: authStub.admin.user.id, sharedWithId: authStub.user1.user.id, }); }); it('should throw an error when the partner already exists', async () => { - partnerMock.get.mockResolvedValue(partnerStub.adminToUser1); + mocks.partner.get.mockResolvedValue(partnerStub.adminToUser1); await expect(sut.create(authStub.admin, authStub.user1.user.id)).rejects.toBeInstanceOf(BadRequestException); - expect(partnerMock.create).not.toHaveBeenCalled(); + expect(mocks.partner.create).not.toHaveBeenCalled(); }); }); describe('remove', () => { it('should remove a partner', async () => { - partnerMock.get.mockResolvedValue(partnerStub.adminToUser1); + mocks.partner.get.mockResolvedValue(partnerStub.adminToUser1); await sut.remove(authStub.admin, authStub.user1.user.id); - expect(partnerMock.remove).toHaveBeenCalledWith(partnerStub.adminToUser1); + expect(mocks.partner.remove).toHaveBeenCalledWith(partnerStub.adminToUser1); }); it('should throw an error when the partner does not exist', async () => { - partnerMock.get.mockResolvedValue(void 0); + mocks.partner.get.mockResolvedValue(void 0); await expect(sut.remove(authStub.admin, authStub.user1.user.id)).rejects.toBeInstanceOf(BadRequestException); - expect(partnerMock.remove).not.toHaveBeenCalled(); + expect(mocks.partner.remove).not.toHaveBeenCalled(); }); }); @@ -83,11 +79,11 @@ describe(PartnerService.name, () => { }); it('should update partner', async () => { - accessMock.partner.checkUpdateAccess.mockResolvedValue(new Set(['shared-by-id'])); - partnerMock.update.mockResolvedValue(partnerStub.adminToUser1); + mocks.access.partner.checkUpdateAccess.mockResolvedValue(new Set(['shared-by-id'])); + mocks.partner.update.mockResolvedValue(partnerStub.adminToUser1); await expect(sut.update(authStub.admin, 'shared-by-id', { inTimeline: true })).resolves.toBeDefined(); - expect(partnerMock.update).toHaveBeenCalledWith( + expect(mocks.partner.update).toHaveBeenCalledWith( { sharedById: 'shared-by-id', sharedWithId: authStub.admin.user.id }, { inTimeline: true }, ); diff --git a/server/src/services/partner.service.ts b/server/src/services/partner.service.ts index f17bab24ba..32b3ae3d3f 100644 --- a/server/src/services/partner.service.ts +++ b/server/src/services/partner.service.ts @@ -4,7 +4,7 @@ import { PartnerResponseDto, PartnerSearchDto, UpdatePartnerDto } from 'src/dtos import { mapUser } from 'src/dtos/user.dto'; import { PartnerEntity } from 'src/entities/partner.entity'; import { Permission } from 'src/enum'; -import { PartnerDirection, PartnerIds } from 'src/interfaces/partner.interface'; +import { PartnerDirection, PartnerIds } from 'src/repositories/partner.repository'; import { BaseService } from 'src/services/base.service'; @Injectable() diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index dc9d7a9329..073cf71247 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -1,27 +1,19 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; -import { PersonResponseDto, mapFaces, mapPerson } from 'src/dtos/person.dto'; +import { mapFaces, mapPerson, PersonResponseDto } from 'src/dtos/person.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { CacheControl, Colorspace, ImageFormat, SourceType, SystemMetadataKey } from 'src/enum'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { DetectedFaces, IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { FaceSearchResult, ISearchRepository } from 'src/interfaces/search.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { CacheControl, Colorspace, ImageFormat, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum'; +import { WithoutProperty } from 'src/repositories/asset.repository'; +import { DetectedFaces } from 'src/repositories/machine-learning.repository'; +import { FaceSearchResult } from 'src/repositories/search.repository'; import { PersonService } from 'src/services/person.service'; -import { IMediaRepository } from 'src/types'; import { ImmichFileResponse } from 'src/utils/file'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { personStub } from 'test/fixtures/person.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { makeStream, newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { makeStream, newTestService, ServiceMocks } from 'test/utils'; const responseDto: PersonResponseDto = { id: 'person-1', @@ -30,6 +22,7 @@ const responseDto: PersonResponseDto = { thumbnailPath: '/path/to/thumbnail.jpg', isHidden: false, updatedAt: expect.any(Date), + isFavorite: false, }; const statistics = { assets: 3 }; @@ -65,32 +58,10 @@ const detectFaceMock: DetectedFaces = { describe(PersonService.name, () => { let sut: PersonService; - - let accessMock: IAccessRepositoryMock; - let assetMock: Mocked; - let cryptoMock: Mocked; - let jobMock: Mocked; - let machineLearningMock: Mocked; - let mediaMock: Mocked; - let personMock: Mocked; - let searchMock: Mocked; - let storageMock: Mocked; - let systemMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ - sut, - accessMock, - assetMock, - cryptoMock, - jobMock, - machineLearningMock, - mediaMock, - personMock, - searchMock, - storageMock, - systemMock, - } = newTestService(PersonService)); + ({ sut, mocks } = newTestService(PersonService)); }); it('should be defined', () => { @@ -99,11 +70,11 @@ describe(PersonService.name, () => { describe('getAll', () => { it('should get all hidden and visible people with thumbnails', async () => { - personMock.getAllForUser.mockResolvedValue({ + mocks.person.getAllForUser.mockResolvedValue({ items: [personStub.withName, personStub.hidden], hasNextPage: false, }); - personMock.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 }); + mocks.person.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 }); await expect(sut.getAll(authStub.admin, { withHidden: true, page: 1, size: 10 })).resolves.toEqual({ hasNextPage: false, total: 2, @@ -116,67 +87,97 @@ describe(PersonService.name, () => { birthDate: null, thumbnailPath: '/path/to/thumbnail.jpg', isHidden: true, + isFavorite: false, updatedAt: expect.any(Date), }, ], }); - expect(personMock.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, { + expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, { minimumFaceCount: 3, withHidden: true, }); }); + + it('should get all visible people and favorites should be first in the array', async () => { + mocks.person.getAllForUser.mockResolvedValue({ + items: [personStub.isFavorite, personStub.withName], + hasNextPage: false, + }); + mocks.person.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 }); + await expect(sut.getAll(authStub.admin, { withHidden: false, page: 1, size: 10 })).resolves.toEqual({ + hasNextPage: false, + total: 2, + hidden: 1, + people: [ + { + id: 'person-4', + name: personStub.isFavorite.name, + birthDate: null, + thumbnailPath: '/path/to/thumbnail.jpg', + isHidden: false, + isFavorite: true, + updatedAt: expect.any(Date), + }, + responseDto, + ], + }); + expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, { + minimumFaceCount: 3, + withHidden: false, + }); + }); }); describe('getById', () => { it('should require person.read permission', async () => { - personMock.getById.mockResolvedValue(personStub.withName); + mocks.person.getById.mockResolvedValue(personStub.withName); await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should throw a bad request when person is not found', async () => { - personMock.getById.mockResolvedValue(null); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.getById.mockResolvedValue(null); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should get a person by id', async () => { - personMock.getById.mockResolvedValue(personStub.withName); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.getById.mockResolvedValue(personStub.withName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.getById(authStub.admin, 'person-1')).resolves.toEqual(responseDto); - expect(personMock.getById).toHaveBeenCalledWith('person-1'); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.getById).toHaveBeenCalledWith('person-1'); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); }); describe('getThumbnail', () => { it('should require person.read permission', async () => { - personMock.getById.mockResolvedValue(personStub.noName); + mocks.person.getById.mockResolvedValue(personStub.noName); await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); - expect(storageMock.createReadStream).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.storage.createReadStream).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should throw an error when personId is invalid', async () => { - personMock.getById.mockResolvedValue(null); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.getById.mockResolvedValue(null); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException); - expect(storageMock.createReadStream).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.storage.createReadStream).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should throw an error when person has no thumbnail', async () => { - personMock.getById.mockResolvedValue(personStub.noThumbnail); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.getById.mockResolvedValue(personStub.noThumbnail); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException); - expect(storageMock.createReadStream).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.storage.createReadStream).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should serve the thumbnail', async () => { - personMock.getById.mockResolvedValue(personStub.noName); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.getById.mockResolvedValue(personStub.noName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.getThumbnail(authStub.admin, 'person-1')).resolves.toEqual( new ImmichFileResponse({ path: '/path/to/thumbnail.jpg', @@ -184,138 +185,156 @@ describe(PersonService.name, () => { cacheControl: CacheControl.PRIVATE_WITHOUT_CACHE, }), ); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); }); describe('update', () => { it('should require person.write permission', async () => { - personMock.getById.mockResolvedValue(personStub.noName); + mocks.person.getById.mockResolvedValue(personStub.noName); await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf( BadRequestException, ); - expect(personMock.update).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should throw an error when personId is invalid', async () => { - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set()); await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf( BadRequestException, ); - expect(personMock.update).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it("should update a person's name", async () => { - personMock.update.mockResolvedValue(personStub.withName); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.update.mockResolvedValue(personStub.withName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto); - expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', name: 'Person 1' }); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', name: 'Person 1' }); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it("should update a person's date of birth", async () => { - personMock.update.mockResolvedValue(personStub.withBirthDate); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.update.mockResolvedValue(personStub.withBirthDate); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); - await expect(sut.update(authStub.admin, 'person-1', { birthDate: '1976-06-30' })).resolves.toEqual({ + await expect(sut.update(authStub.admin, 'person-1', { birthDate: new Date('1976-06-30') })).resolves.toEqual({ id: 'person-1', name: 'Person 1', birthDate: '1976-06-30', thumbnailPath: '/path/to/thumbnail.jpg', isHidden: false, + isFavorite: false, updatedAt: expect.any(Date), }); - expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: '1976-06-30' }); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: new Date('1976-06-30') }); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should update a person visibility', async () => { - personMock.update.mockResolvedValue(personStub.withName); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.update.mockResolvedValue(personStub.withName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto); - expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false }); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false }); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + }); + + it('should update a person favorite status', async () => { + mocks.person.update.mockResolvedValue(personStub.withName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + + await expect(sut.update(authStub.admin, 'person-1', { isFavorite: true })).resolves.toEqual(responseDto); + + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', isFavorite: true }); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it("should update a person's thumbnailPath", async () => { - personMock.update.mockResolvedValue(personStub.withName); - personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.update.mockResolvedValue(personStub.withName); + mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect( sut.update(authStub.admin, 'person-1', { featureFaceAssetId: faceStub.face1.assetId }), ).resolves.toEqual(responseDto); - expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: faceStub.face1.id }); - expect(personMock.getFacesByIds).toHaveBeenCalledWith([ + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: faceStub.face1.id }); + expect(mocks.person.getFacesByIds).toHaveBeenCalledWith([ { assetId: faceStub.face1.assetId, personId: 'person-1', }, ]); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: 'person-1' } }); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.GENERATE_PERSON_THUMBNAIL, + data: { id: 'person-1' }, + }); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should throw an error when the face feature assetId is invalid', async () => { - personMock.getById.mockResolvedValue(personStub.withName); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.getById.mockResolvedValue(personStub.withName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { featureFaceAssetId: '-1' })).rejects.toThrow( BadRequestException, ); - expect(personMock.update).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); }); describe('updateAll', () => { it('should throw an error when personId is invalid', async () => { - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set()); await expect(sut.updateAll(authStub.admin, { people: [{ id: 'person-1', name: 'Person 1' }] })).resolves.toEqual([ { error: BulkIdErrorReason.UNKNOWN, id: 'person-1', success: false }, ]); - expect(personMock.update).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); }); describe('reassignFaces', () => { it('should throw an error if user has no access to the person', async () => { - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set()); await expect( sut.reassignFaces(authStub.admin, personStub.noName.id, { data: [{ personId: 'asset-face-1', assetId: '' }], }), ).rejects.toBeInstanceOf(BadRequestException); - expect(jobMock.queue).not.toHaveBeenCalledWith(); - expect(jobMock.queueAll).not.toHaveBeenCalledWith(); + expect(mocks.job.queue).not.toHaveBeenCalledWith(); + expect(mocks.job.queueAll).not.toHaveBeenCalledWith(); }); it('should reassign a face', async () => { - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.withName.id])); - personMock.getById.mockResolvedValue(personStub.noName); - accessMock.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id])); - personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); - personMock.reassignFace.mockResolvedValue(1); - personMock.getRandomFace.mockResolvedValue(faceStub.primaryFace1); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.withName.id])); + mocks.person.getById.mockResolvedValue(personStub.noName); + mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id])); + mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]); + mocks.person.reassignFace.mockResolvedValue(1); + mocks.person.getRandomFace.mockResolvedValue(faceStub.primaryFace1); + mocks.person.refreshFaces.mockResolvedValue(); + mocks.person.reassignFace.mockResolvedValue(5); + mocks.person.update.mockResolvedValue(personStub.noName); + await expect( sut.reassignFaces(authStub.admin, personStub.noName.id, { data: [{ personId: personStub.withName.id, assetId: assetStub.image.id }], }), - ).resolves.toEqual([personStub.noName]); + ).resolves.toBeDefined(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personStub.newThumbnail.id }, @@ -326,22 +345,22 @@ describe(PersonService.name, () => { describe('handlePersonMigration', () => { it('should not move person files', async () => { - personMock.getById.mockResolvedValue(null); + mocks.person.getById.mockResolvedValue(null); await expect(sut.handlePersonMigration(personStub.noName)).resolves.toBe(JobStatus.FAILED); }); }); describe('getFacesById', () => { it('should get the bounding boxes for an asset', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([faceStub.face1.assetId])); - personMock.getFaces.mockResolvedValue([faceStub.primaryFace1]); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([faceStub.face1.assetId])); + mocks.person.getFaces.mockResolvedValue([faceStub.primaryFace1]); await expect(sut.getFacesById(authStub.admin, { id: faceStub.face1.assetId })).resolves.toStrictEqual([ mapFaces(faceStub.primaryFace1, authStub.admin), ]); }); it('should reject if the user has not access to the asset', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set()); - personMock.getFaces.mockResolvedValue([faceStub.primaryFace1]); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.person.getFaces.mockResolvedValue([faceStub.primaryFace1]); await expect(sut.getFacesById(authStub.admin, { id: faceStub.primaryFace1.assetId })).rejects.toBeInstanceOf( BadRequestException, ); @@ -350,9 +369,9 @@ describe(PersonService.name, () => { describe('createNewFeaturePhoto', () => { it('should change person feature photo', async () => { - personMock.getRandomFace.mockResolvedValue(faceStub.primaryFace1); + mocks.person.getRandomFace.mockResolvedValue(faceStub.primaryFace1); await sut.createNewFeaturePhoto([personStub.newThumbnail.id]); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personStub.newThumbnail.id }, @@ -363,11 +382,11 @@ describe(PersonService.name, () => { describe('reassignFacesById', () => { it('should create a new person', async () => { - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id])); - accessMock.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id])); - personMock.getFaceById.mockResolvedValue(faceStub.face1); - personMock.reassignFace.mockResolvedValue(1); - personMock.getById.mockResolvedValue(personStub.noName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id])); + mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id])); + mocks.person.getFaceById.mockResolvedValue(faceStub.face1); + mocks.person.reassignFace.mockResolvedValue(1); + mocks.person.getById.mockResolvedValue(personStub.noName); await expect( sut.reassignFacesById(authStub.admin, personStub.noName.id, { id: faceStub.face1.id, @@ -375,73 +394,74 @@ describe(PersonService.name, () => { ).resolves.toEqual({ birthDate: personStub.noName.birthDate, isHidden: personStub.noName.isHidden, + isFavorite: personStub.noName.isFavorite, id: personStub.noName.id, name: personStub.noName.name, thumbnailPath: personStub.noName.thumbnailPath, updatedAt: expect.any(Date), }); - expect(jobMock.queue).not.toHaveBeenCalledWith(); - expect(jobMock.queueAll).not.toHaveBeenCalledWith(); + expect(mocks.job.queue).not.toHaveBeenCalledWith(); + expect(mocks.job.queueAll).not.toHaveBeenCalledWith(); }); it('should fail if user has not the correct permissions on the asset', async () => { - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id])); - personMock.getFaceById.mockResolvedValue(faceStub.face1); - personMock.reassignFace.mockResolvedValue(1); - personMock.getById.mockResolvedValue(personStub.noName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id])); + mocks.person.getFaceById.mockResolvedValue(faceStub.face1); + mocks.person.reassignFace.mockResolvedValue(1); + mocks.person.getById.mockResolvedValue(personStub.noName); await expect( sut.reassignFacesById(authStub.admin, personStub.noName.id, { id: faceStub.face1.id, }), ).rejects.toBeInstanceOf(BadRequestException); - expect(jobMock.queue).not.toHaveBeenCalledWith(); - expect(jobMock.queueAll).not.toHaveBeenCalledWith(); + expect(mocks.job.queue).not.toHaveBeenCalledWith(); + expect(mocks.job.queueAll).not.toHaveBeenCalledWith(); }); }); describe('createPerson', () => { it('should create a new person', async () => { - personMock.create.mockResolvedValue(personStub.primaryPerson); + mocks.person.create.mockResolvedValue(personStub.primaryPerson); - await expect(sut.create(authStub.admin, {})).resolves.toBe(personStub.primaryPerson); + await expect(sut.create(authStub.admin, {})).resolves.toBeDefined(); - expect(personMock.create).toHaveBeenCalledWith({ ownerId: authStub.admin.user.id }); + expect(mocks.person.create).toHaveBeenCalledWith({ ownerId: authStub.admin.user.id }); }); }); describe('handlePersonCleanup', () => { it('should delete people without faces', async () => { - personMock.getAllWithoutFaces.mockResolvedValue([personStub.noName]); + mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.noName]); await sut.handlePersonCleanup(); - expect(personMock.delete).toHaveBeenCalledWith([personStub.noName]); - expect(storageMock.unlink).toHaveBeenCalledWith(personStub.noName.thumbnailPath); + expect(mocks.person.delete).toHaveBeenCalledWith([personStub.noName]); + expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.noName.thumbnailPath); }); }); describe('handleQueueDetectFaces', () => { it('should skip if machine learning is disabled', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); await expect(sut.handleQueueDetectFaces({})).resolves.toBe(JobStatus.SKIPPED); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(systemMock.get).toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).toHaveBeenCalled(); }); it('should queue missing assets', async () => { - assetMock.getWithout.mockResolvedValue({ + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); await sut.handleQueueDetectFaces({ force: false }); - expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.FACES); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.FACES); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACE_DETECTION, data: { id: assetStub.image.id }, @@ -450,19 +470,19 @@ describe(PersonService.name, () => { }); it('should queue all assets', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); - personMock.getAllWithoutFaces.mockResolvedValue([personStub.withName]); + mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.withName]); await sut.handleQueueDetectFaces({ force: true }); - expect(personMock.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); - expect(personMock.delete).toHaveBeenCalledWith([personStub.withName]); - expect(storageMock.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); + expect(mocks.person.delete).toHaveBeenCalledWith([personStub.withName]); + expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath); + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACE_DETECTION, data: { id: assetStub.image.id }, @@ -471,127 +491,167 @@ describe(PersonService.name, () => { }); it('should refresh all assets', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); await sut.handleQueueDetectFaces({ force: undefined }); - expect(personMock.delete).not.toHaveBeenCalled(); - expect(personMock.deleteFaces).not.toHaveBeenCalled(); - expect(storageMock.unlink).not.toHaveBeenCalled(); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.delete).not.toHaveBeenCalled(); + expect(mocks.person.deleteFaces).not.toHaveBeenCalled(); + expect(mocks.storage.unlink).not.toHaveBeenCalled(); + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACE_DETECTION, data: { id: assetStub.image.id }, }, ]); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.PERSON_CLEANUP }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.PERSON_CLEANUP }); }); it('should delete existing people and faces if forced', async () => { - personMock.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson])); - personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); - assetMock.getAll.mockResolvedValue({ + mocks.person.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson])); + mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); - personMock.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); + mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); + mocks.person.deleteFaces.mockResolvedValue(); await sut.handleQueueDetectFaces({ force: true }); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACE_DETECTION, data: { id: assetStub.image.id }, }, ]); - expect(personMock.delete).toHaveBeenCalledWith([personStub.randomPerson]); - expect(storageMock.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath); + expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson]); + expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath); }); }); describe('handleQueueRecognizeFaces', () => { it('should skip if machine learning is disabled', async () => { - jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); - systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); + mocks.job.getJobCounts.mockResolvedValue({ + active: 1, + waiting: 0, + paused: 0, + completed: 0, + failed: 0, + delayed: 0, + }); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); await expect(sut.handleQueueRecognizeFaces({})).resolves.toBe(JobStatus.SKIPPED); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(systemMock.get).toHaveBeenCalled(); - expect(systemMock.set).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).toHaveBeenCalled(); + expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); }); it('should skip if recognition jobs are already queued', async () => { - jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 1, paused: 0, completed: 0, failed: 0, delayed: 0 }); + mocks.job.getJobCounts.mockResolvedValue({ + active: 1, + waiting: 1, + paused: 0, + completed: 0, + failed: 0, + delayed: 0, + }); await expect(sut.handleQueueRecognizeFaces({})).resolves.toBe(JobStatus.SKIPPED); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(systemMock.set).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); }); it('should queue missing assets', async () => { - jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); - personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); - personMock.getAllWithoutFaces.mockResolvedValue([]); + mocks.job.getJobCounts.mockResolvedValue({ + active: 1, + waiting: 0, + paused: 0, + completed: 0, + failed: 0, + delayed: 0, + }); + mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({}); - expect(personMock.getAllFaces).toHaveBeenCalledWith({ personId: null, sourceType: SourceType.MACHINE_LEARNING }); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.getAllFaces).toHaveBeenCalledWith({ + personId: null, + sourceType: SourceType.MACHINE_LEARNING, + }); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, data: { id: faceStub.face1.id, deferred: false }, }, ]); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { lastRun: expect.any(String), }); }); it('should queue all assets', async () => { - jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); - personMock.getAll.mockReturnValue(makeStream()); - personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); - personMock.getAllWithoutFaces.mockResolvedValue([]); + mocks.job.getJobCounts.mockResolvedValue({ + active: 1, + waiting: 0, + paused: 0, + completed: 0, + failed: 0, + delayed: 0, + }); + mocks.person.getAll.mockReturnValue(makeStream()); + mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({ force: true }); - expect(personMock.getAllFaces).toHaveBeenCalledWith(undefined); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.getAllFaces).toHaveBeenCalledWith(undefined); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, data: { id: faceStub.face1.id, deferred: false }, }, ]); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { lastRun: expect.any(String), }); }); it('should run nightly if new face has been added since last run', async () => { - personMock.getLatestFaceDate.mockResolvedValue(new Date().toISOString()); - personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); - jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); - personMock.getAll.mockReturnValue(makeStream()); - personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); - personMock.getAllWithoutFaces.mockResolvedValue([]); + mocks.person.getLatestFaceDate.mockResolvedValue(new Date().toISOString()); + mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.job.getJobCounts.mockResolvedValue({ + active: 1, + waiting: 0, + paused: 0, + completed: 0, + failed: 0, + delayed: 0, + }); + mocks.person.getAll.mockReturnValue(makeStream()); + mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllWithoutFaces.mockResolvedValue([]); + mocks.person.unassignFaces.mockResolvedValue(); await sut.handleQueueRecognizeFaces({ force: true, nightly: true }); - expect(systemMock.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE); - expect(personMock.getLatestFaceDate).toHaveBeenCalledOnce(); - expect(personMock.getAllFaces).toHaveBeenCalledWith(undefined); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.systemMetadata.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE); + expect(mocks.person.getLatestFaceDate).toHaveBeenCalledOnce(); + expect(mocks.person.getAllFaces).toHaveBeenCalledWith(undefined); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, data: { id: faceStub.face1.id, deferred: false }, }, ]); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { lastRun: expect.any(String), }); }); @@ -599,62 +659,70 @@ describe(PersonService.name, () => { it('should skip nightly if no new face has been added since last run', async () => { const lastRun = new Date(); - systemMock.get.mockResolvedValue({ lastRun: lastRun.toISOString() }); - personMock.getLatestFaceDate.mockResolvedValue(new Date(lastRun.getTime() - 1).toISOString()); - personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); - personMock.getAllWithoutFaces.mockResolvedValue([]); + mocks.systemMetadata.get.mockResolvedValue({ lastRun: lastRun.toISOString() }); + mocks.person.getLatestFaceDate.mockResolvedValue(new Date(lastRun.getTime() - 1).toISOString()); + mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({ force: true, nightly: true }); - expect(systemMock.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE); - expect(personMock.getLatestFaceDate).toHaveBeenCalledOnce(); - expect(personMock.getAllFaces).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(systemMock.set).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE); + expect(mocks.person.getLatestFaceDate).toHaveBeenCalledOnce(); + expect(mocks.person.getAllFaces).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); }); it('should delete existing people if forced', async () => { - jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); - personMock.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson])); - personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); - personMock.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); + mocks.job.getJobCounts.mockResolvedValue({ + active: 1, + waiting: 0, + paused: 0, + completed: 0, + failed: 0, + delayed: 0, + }); + mocks.person.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson])); + mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); + mocks.person.unassignFaces.mockResolvedValue(); await sut.handleQueueRecognizeFaces({ force: true }); - expect(personMock.deleteFaces).not.toHaveBeenCalled(); - expect(personMock.unassignFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.deleteFaces).not.toHaveBeenCalled(); + expect(mocks.person.unassignFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, data: { id: faceStub.face1.id, deferred: false }, }, ]); - expect(personMock.delete).toHaveBeenCalledWith([personStub.randomPerson]); - expect(storageMock.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath); + expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson]); + expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath); }); }); describe('handleDetectFaces', () => { beforeEach(() => { - cryptoMock.randomUUID.mockReturnValue(faceId); + mocks.crypto.randomUUID.mockReturnValue(faceId); }); it('should skip if machine learning is disabled', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); await expect(sut.handleDetectFaces({ id: 'foo' })).resolves.toBe(JobStatus.SKIPPED); - expect(assetMock.getByIds).not.toHaveBeenCalled(); - expect(systemMock.get).toHaveBeenCalled(); + expect(mocks.asset.getByIds).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).toHaveBeenCalled(); }); it('should skip when no resize path', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]); + mocks.asset.getByIds.mockResolvedValue([assetStub.noResizePath]); await sut.handleDetectFaces({ id: assetStub.noResizePath.id }); - expect(machineLearningMock.detectFaces).not.toHaveBeenCalled(); + expect(mocks.machineLearning.detectFaces).not.toHaveBeenCalled(); }); it('should skip it the asset has already been processed', async () => { - assetMock.getByIds.mockResolvedValue([ + mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.noResizePath, faces: [ @@ -667,103 +735,106 @@ describe(PersonService.name, () => { }, ]); await sut.handleDetectFaces({ id: assetStub.noResizePath.id }); - expect(machineLearningMock.detectFaces).not.toHaveBeenCalled(); + expect(mocks.machineLearning.detectFaces).not.toHaveBeenCalled(); }); it('should handle no results', async () => { const start = Date.now(); - machineLearningMock.detectFaces.mockResolvedValue({ imageHeight: 500, imageWidth: 400, faces: [] }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.machineLearning.detectFaces.mockResolvedValue({ imageHeight: 500, imageWidth: 400, faces: [] }); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); await sut.handleDetectFaces({ id: assetStub.image.id }); - expect(machineLearningMock.detectFaces).toHaveBeenCalledWith( + expect(mocks.machineLearning.detectFaces).toHaveBeenCalledWith( ['http://immich-machine-learning:3003'], '/uploads/user-id/thumbs/path.jpg', expect.objectContaining({ minScore: 0.7, modelName: 'buffalo_l' }), ); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); - expect(assetMock.upsertJobStatus).toHaveBeenCalledWith({ + expect(mocks.asset.upsertJobStatus).toHaveBeenCalledWith({ assetId: assetStub.image.id, facesRecognizedAt: expect.any(Date), }); - const facesRecognizedAt = assetMock.upsertJobStatus.mock.calls[0][0].facesRecognizedAt as Date; + const facesRecognizedAt = mocks.asset.upsertJobStatus.mock.calls[0][0].facesRecognizedAt as Date; expect(facesRecognizedAt.getTime()).toBeGreaterThan(start); }); it('should create a face with no person and queue recognition job', async () => { - machineLearningMock.detectFaces.mockResolvedValue(detectFaceMock); - searchMock.searchFaces.mockResolvedValue([{ ...faceStub.face1, distance: 0.7 }]); - assetMock.getByIds.mockResolvedValue([assetStub.image]); + mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); + mocks.search.searchFaces.mockResolvedValue([{ ...faceStub.face1, distance: 0.7 }]); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.person.refreshFaces.mockResolvedValue(); await sut.handleDetectFaces({ id: assetStub.image.id }); - expect(personMock.refreshFaces).toHaveBeenCalledWith([face], [], [faceSearch]); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.refreshFaces).toHaveBeenCalledWith([face], [], [faceSearch]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }, { name: JobName.FACIAL_RECOGNITION, data: { id: faceId } }, ]); - expect(personMock.reassignFace).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.person.reassignFace).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should delete an existing face not among the new detected faces', async () => { - machineLearningMock.detectFaces.mockResolvedValue({ faces: [], imageHeight: 500, imageWidth: 400 }); - assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.primaryFace1] }]); + mocks.machineLearning.detectFaces.mockResolvedValue({ faces: [], imageHeight: 500, imageWidth: 400 }); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.primaryFace1] }]); await sut.handleDetectFaces({ id: assetStub.image.id }); - expect(personMock.refreshFaces).toHaveBeenCalledWith([], [faceStub.primaryFace1.id], []); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(personMock.reassignFace).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.person.refreshFaces).toHaveBeenCalledWith([], [faceStub.primaryFace1.id], []); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.person.reassignFace).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should add new face and delete an existing face not among the new detected faces', async () => { - machineLearningMock.detectFaces.mockResolvedValue(detectFaceMock); - assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.primaryFace1] }]); + mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.primaryFace1] }]); + mocks.person.refreshFaces.mockResolvedValue(); await sut.handleDetectFaces({ id: assetStub.image.id }); - expect(personMock.refreshFaces).toHaveBeenCalledWith([face], [faceStub.primaryFace1.id], [faceSearch]); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.refreshFaces).toHaveBeenCalledWith([face], [faceStub.primaryFace1.id], [faceSearch]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }, { name: JobName.FACIAL_RECOGNITION, data: { id: faceId } }, ]); - expect(personMock.reassignFace).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.person.reassignFace).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should add embedding to matching metadata face', async () => { - machineLearningMock.detectFaces.mockResolvedValue(detectFaceMock); - assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.fromExif1] }]); + mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.fromExif1] }]); + mocks.person.refreshFaces.mockResolvedValue(); await sut.handleDetectFaces({ id: assetStub.image.id }); - expect(personMock.refreshFaces).toHaveBeenCalledWith( + expect(mocks.person.refreshFaces).toHaveBeenCalledWith( [], [], [{ faceId: faceStub.fromExif1.id, embedding: faceSearch.embedding }], ); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(personMock.reassignFace).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.person.reassignFace).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should not add embedding to non-matching metadata face', async () => { - machineLearningMock.detectFaces.mockResolvedValue(detectFaceMock); - assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.fromExif2] }]); + mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); + mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.fromExif2] }]); await sut.handleDetectFaces({ id: assetStub.image.id }); - expect(personMock.refreshFaces).toHaveBeenCalledWith([face], [], [faceSearch]); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.person.refreshFaces).toHaveBeenCalledWith([face], [], [faceSearch]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }, { name: JobName.FACIAL_RECOGNITION, data: { id: faceId } }, ]); - expect(personMock.reassignFace).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.person.reassignFace).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); }); @@ -771,27 +842,27 @@ describe(PersonService.name, () => { it('should fail if face does not exist', async () => { expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.FAILED); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); - expect(personMock.create).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.person.create).not.toHaveBeenCalled(); }); it('should fail if face does not have asset', async () => { const face = { ...faceStub.face1, asset: null } as AssetFaceEntity & { asset: null }; - personMock.getFaceByIdWithAssets.mockResolvedValue(face); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(face); expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.FAILED); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); - expect(personMock.create).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.person.create).not.toHaveBeenCalled(); }); it('should skip if face already has an assigned person', async () => { - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.SKIPPED); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); - expect(personMock.create).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.person.create).not.toHaveBeenCalled(); }); it('should match existing person', async () => { @@ -806,20 +877,20 @@ describe(PersonService.name, () => { { ...faceStub.face1, distance: 0.4 }, ] as FaceSearchResult[]; - systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); - searchMock.searchFaces.mockResolvedValue(faces); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue(faceStub.primaryFace1.person); + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); + mocks.search.searchFaces.mockResolvedValue(faces); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); + mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); - expect(personMock.create).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).toHaveBeenCalledTimes(1); - expect(personMock.reassignFaces).toHaveBeenCalledWith({ + expect(mocks.person.create).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1); + expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ faceIds: expect.arrayContaining([faceStub.noPerson1.id]), newPersonId: faceStub.primaryFace1.person.id, }); - expect(personMock.reassignFaces).toHaveBeenCalledWith({ + expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ faceIds: expect.not.arrayContaining([faceStub.face1.id]), newPersonId: faceStub.primaryFace1.person.id, }); @@ -831,18 +902,18 @@ describe(PersonService.name, () => { { ...faceStub.noPerson2, distance: 0.3 }, ] as FaceSearchResult[]; - systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); - searchMock.searchFaces.mockResolvedValue(faces); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue(personStub.withName); + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); + mocks.search.searchFaces.mockResolvedValue(faces); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); + mocks.person.create.mockResolvedValue(personStub.withName); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); - expect(personMock.create).toHaveBeenCalledWith({ + expect(mocks.person.create).toHaveBeenCalledWith({ ownerId: faceStub.noPerson1.asset.ownerId, faceAssetId: faceStub.noPerson1.id, }); - expect(personMock.reassignFaces).toHaveBeenCalledWith({ + expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ faceIds: [faceStub.noPerson1.id], newPersonId: personStub.withName.id, }); @@ -851,16 +922,16 @@ describe(PersonService.name, () => { it('should not queue face with no matches', async () => { const faces = [{ ...faceStub.noPerson1, distance: 0 }] as FaceSearchResult[]; - searchMock.searchFaces.mockResolvedValue(faces); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue(personStub.withName); + mocks.search.searchFaces.mockResolvedValue(faces); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); + mocks.person.create.mockResolvedValue(personStub.withName); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(searchMock.searchFaces).toHaveBeenCalledTimes(1); - expect(personMock.create).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.search.searchFaces).toHaveBeenCalledTimes(1); + expect(mocks.person.create).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should defer non-core faces to end of queue', async () => { @@ -869,20 +940,20 @@ describe(PersonService.name, () => { { ...faceStub.noPerson2, distance: 0.4 }, ] as FaceSearchResult[]; - systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); - searchMock.searchFaces.mockResolvedValue(faces); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue(personStub.withName); + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); + mocks.search.searchFaces.mockResolvedValue(faces); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); + mocks.person.create.mockResolvedValue(personStub.withName); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FACIAL_RECOGNITION, data: { id: faceStub.noPerson1.id, deferred: true }, }); - expect(searchMock.searchFaces).toHaveBeenCalledTimes(1); - expect(personMock.create).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.search.searchFaces).toHaveBeenCalledTimes(1); + expect(mocks.person.create).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should not assign person to deferred non-core face with no matching person', async () => { @@ -891,66 +962,67 @@ describe(PersonService.name, () => { { ...faceStub.noPerson2, distance: 0.4 }, ] as FaceSearchResult[]; - systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); - searchMock.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue(personStub.withName); + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); + mocks.search.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); + mocks.person.create.mockResolvedValue(personStub.withName); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id, deferred: true }); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(searchMock.searchFaces).toHaveBeenCalledTimes(2); - expect(personMock.create).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.search.searchFaces).toHaveBeenCalledTimes(2); + expect(mocks.person.create).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); }); describe('handleGeneratePersonThumbnail', () => { it('should skip if machine learning is disabled', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); await expect(sut.handleGeneratePersonThumbnail({ id: 'person-1' })).resolves.toBe(JobStatus.SKIPPED); - expect(assetMock.getByIds).not.toHaveBeenCalled(); - expect(systemMock.get).toHaveBeenCalled(); + expect(mocks.asset.getByIds).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).toHaveBeenCalled(); }); it('should skip a person not found', async () => { - personMock.getById.mockResolvedValue(null); + mocks.person.getById.mockResolvedValue(null); await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); }); it('should skip a person without a face asset id', async () => { - personMock.getById.mockResolvedValue(personStub.noThumbnail); + mocks.person.getById.mockResolvedValue(personStub.noThumbnail); await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); }); it('should skip a person with a face asset id not found', async () => { - personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.id }); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); + mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.id }); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); }); it('should skip a person with a face asset id without a thumbnail', async () => { - personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId }); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); - assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]); + mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId }); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); + mocks.asset.getByIds.mockResolvedValue([assetStub.noResizePath]); await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); }); it('should generate a thumbnail', async () => { - personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId }); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.middle); - assetMock.getById.mockResolvedValue(assetStub.primaryImage); + mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId }); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.middle); + mocks.asset.getById.mockResolvedValue(assetStub.primaryImage); + mocks.media.generateThumbnail.mockResolvedValue(); await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); - expect(assetMock.getById).toHaveBeenCalledWith(faceStub.middle.assetId, { exifInfo: true, files: true }); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs'); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.asset.getById).toHaveBeenCalledWith(faceStub.middle.assetId, { exifInfo: true, files: true }); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs'); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( assetStub.primaryImage.originalPath, { colorspace: Colorspace.P3, @@ -967,20 +1039,21 @@ describe(PersonService.name, () => { }, 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', ); - expect(personMock.update).toHaveBeenCalledWith({ + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', thumbnailPath: 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', }); }); it('should generate a thumbnail without going negative', async () => { - personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.start.assetId }); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.start); - assetMock.getById.mockResolvedValue(assetStub.image); + mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.start.assetId }); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.start); + mocks.asset.getById.mockResolvedValue(assetStub.image); + mocks.media.generateThumbnail.mockResolvedValue(); await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( assetStub.primaryImage.originalPath, { colorspace: Colorspace.P3, @@ -1000,13 +1073,15 @@ describe(PersonService.name, () => { }); it('should generate a thumbnail without overflowing', async () => { - personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId }); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.end); - assetMock.getById.mockResolvedValue(assetStub.primaryImage); + mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId }); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.end); + mocks.person.update.mockResolvedValue(personStub.primaryPerson); + mocks.asset.getById.mockResolvedValue(assetStub.primaryImage); + mocks.media.generateThumbnail.mockResolvedValue(); await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( assetStub.primaryImage.originalPath, { colorspace: Colorspace.P3, @@ -1028,117 +1103,117 @@ describe(PersonService.name, () => { describe('mergePerson', () => { it('should require person.write and person.merge permission', async () => { - personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); - personMock.getById.mockResolvedValueOnce(personStub.mergePerson); + mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson); + mocks.person.getById.mockResolvedValueOnce(personStub.mergePerson); await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf( BadRequestException, ); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); - expect(personMock.delete).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.delete).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should merge two people without smart merge', async () => { - personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); - personMock.getById.mockResolvedValueOnce(personStub.mergePerson); - accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); - accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); + mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson); + mocks.person.getById.mockResolvedValueOnce(personStub.mergePerson); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ { id: 'person-2', success: true }, ]); - expect(personMock.reassignFaces).toHaveBeenCalledWith({ + expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ newPersonId: personStub.primaryPerson.id, oldPersonId: personStub.mergePerson.id, }); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should merge two people with smart merge', async () => { - personMock.getById.mockResolvedValueOnce(personStub.randomPerson); - personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); - personMock.update.mockResolvedValue({ ...personStub.randomPerson, name: personStub.primaryPerson.name }); - accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-3'])); - accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); + mocks.person.getById.mockResolvedValueOnce(personStub.randomPerson); + mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson); + mocks.person.update.mockResolvedValue({ ...personStub.randomPerson, name: personStub.primaryPerson.name }); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-3'])); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); await expect(sut.mergePerson(authStub.admin, 'person-3', { ids: ['person-1'] })).resolves.toEqual([ { id: 'person-1', success: true }, ]); - expect(personMock.reassignFaces).toHaveBeenCalledWith({ + expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ newPersonId: personStub.randomPerson.id, oldPersonId: personStub.primaryPerson.id, }); - expect(personMock.update).toHaveBeenCalledWith({ + expect(mocks.person.update).toHaveBeenCalledWith({ id: personStub.randomPerson.id, name: personStub.primaryPerson.name, }); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should throw an error when the primary person is not found', async () => { - personMock.getById.mockResolvedValue(null); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.getById.mockResolvedValue(null); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf( BadRequestException, ); - expect(personMock.delete).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.delete).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should handle invalid merge ids', async () => { - personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); - personMock.getById.mockResolvedValueOnce(null); - accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); - accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); + mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson); + mocks.person.getById.mockResolvedValueOnce(null); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ { id: 'person-2', success: false, error: BulkIdErrorReason.NOT_FOUND }, ]); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); - expect(personMock.delete).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); + expect(mocks.person.delete).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should handle an error reassigning faces', async () => { - personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); - personMock.getById.mockResolvedValueOnce(personStub.mergePerson); - personMock.reassignFaces.mockRejectedValue(new Error('update failed')); - accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); - accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); + mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson); + mocks.person.getById.mockResolvedValueOnce(personStub.mergePerson); + mocks.person.reassignFaces.mockRejectedValue(new Error('update failed')); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ { id: 'person-2', success: false, error: BulkIdErrorReason.UNKNOWN }, ]); - expect(personMock.delete).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.person.delete).not.toHaveBeenCalled(); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); }); describe('getStatistics', () => { it('should get correct number of person', async () => { - personMock.getById.mockResolvedValue(personStub.primaryPerson); - personMock.getStatistics.mockResolvedValue(statistics); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + mocks.person.getById.mockResolvedValue(personStub.primaryPerson); + mocks.person.getStatistics.mockResolvedValue(statistics); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.getStatistics(authStub.admin, 'person-1')).resolves.toEqual({ assets: 3 }); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should require person.read permission', async () => { - personMock.getById.mockResolvedValue(personStub.primaryPerson); + mocks.person.getById.mockResolvedValue(personStub.primaryPerson); await expect(sut.getStatistics(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index bcc65cfad3..e297910a95 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -1,13 +1,17 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { FACE_THUMBNAIL_SIZE } from 'src/constants'; +import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { OnJob } from 'src/decorators'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { + AssetFaceCreateDto, + AssetFaceDeleteDto, AssetFaceResponseDto, AssetFaceUpdateDto, FaceDto, + mapFaces, + mapPerson, MergePersonDto, PeopleResponseDto, PeopleUpdateDto, @@ -16,8 +20,6 @@ import { PersonSearchDto, PersonStatisticsResponseDto, PersonUpdateDto, - mapFaces, - mapPerson, } from 'src/dtos/person.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; @@ -27,24 +29,19 @@ import { AssetType, CacheControl, ImageFormat, + JobName, + JobStatus, Permission, PersonPathType, + QueueName, SourceType, SystemMetadataKey, } from 'src/enum'; -import { WithoutProperty } from 'src/interfaces/asset.interface'; -import { - JOBS_ASSET_PAGINATION_SIZE, - JobItem, - JobName, - JobOf, - JobStatus, - QueueName, -} from 'src/interfaces/job.interface'; -import { BoundingBox } from 'src/interfaces/machine-learning.interface'; -import { UpdateFacesData } from 'src/interfaces/person.interface'; +import { WithoutProperty } from 'src/repositories/asset.repository'; +import { BoundingBox } from 'src/repositories/machine-learning.repository'; +import { UpdateFacesData } from 'src/repositories/person.repository'; import { BaseService } from 'src/services/base.service'; -import { CropOptions, ImageDimensions, InputDimensions } from 'src/types'; +import { CropOptions, ImageDimensions, InputDimensions, JobItem, JobOf } from 'src/types'; import { getAssetFiles } from 'src/utils/asset.util'; import { ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; @@ -104,7 +101,7 @@ export class PersonService extends BaseService { await this.personRepository.reassignFace(face.id, personId); } - result.push(person); + result.push(mapPerson(person)); } if (changeFeaturePhoto.length > 0) { // Remove duplicates @@ -178,19 +175,23 @@ export class PersonService extends BaseService { }); } - create(auth: AuthDto, dto: PersonCreateDto): Promise { - return this.personRepository.create({ + async create(auth: AuthDto, dto: PersonCreateDto): Promise { + const person = await this.personRepository.create({ ownerId: auth.user.id, name: dto.name, birthDate: dto.birthDate, isHidden: dto.isHidden, + isFavorite: dto.isFavorite, + color: dto.color, }); + + return mapPerson(person); } async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise { await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [id] }); - const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto; + const { name, birthDate, isHidden, featureFaceAssetId: assetId, isFavorite, color } = dto; // TODO: set by faceId directly let faceId: string | undefined = undefined; if (assetId) { @@ -203,7 +204,15 @@ export class PersonService extends BaseService { faceId = face.id; } - const person = await this.personRepository.update({ id, faceAssetId: faceId, name, birthDate, isHidden }); + const person = await this.personRepository.update({ + id, + faceAssetId: faceId, + name, + birthDate, + isHidden, + isFavorite, + color, + }); if (assetId) { await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } }); @@ -221,6 +230,7 @@ export class PersonService extends BaseService { name: person.name, birthDate: person.birthDate, featureFaceAssetId: person.featureFaceAssetId, + isFavorite: person.isFavorite, }); results.push({ id: person.id, success: true }); } catch (error: Error | any) { @@ -287,7 +297,7 @@ export class PersonService extends BaseService { return JobStatus.SKIPPED; } - const relations = { exifInfo: true, faces: { person: false }, files: true }; + const relations = { exifInfo: true, faces: { person: false, withDeleted: true }, files: true }; const [asset] = await this.assetRepository.getByIds([id], relations); const { previewFile } = getAssetFiles(asset.files); if (!asset || !previewFile) { @@ -709,4 +719,30 @@ export class PersonService extends BaseService { height: newHalfSize * 2, }; } + + // TODO return a asset face response + async createFace(auth: AuthDto, dto: AssetFaceCreateDto): Promise { + await Promise.all([ + this.requireAccess({ auth, permission: Permission.ASSET_READ, ids: [dto.assetId] }), + this.requireAccess({ auth, permission: Permission.PERSON_READ, ids: [dto.personId] }), + ]); + + await this.personRepository.createAssetFace({ + personId: dto.personId, + assetId: dto.assetId, + imageHeight: dto.imageHeight, + imageWidth: dto.imageWidth, + boundingBoxX1: dto.x, + boundingBoxX2: dto.x + dto.width, + boundingBoxY1: dto.y, + boundingBoxY2: dto.y + dto.height, + sourceType: SourceType.MANUAL, + }); + } + + async deleteFace(auth: AuthDto, id: string, dto: AssetFaceDeleteDto): Promise { + await this.requireAccess({ auth, permission: Permission.FACE_DELETE, ids: [id] }); + + return dto.force ? this.personRepository.deleteAssetFace(id) : this.personRepository.softDeleteAssetFaces(id); + } } diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index 5c59e24b21..79f3a77ebe 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -1,26 +1,20 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { SearchSuggestionType } from 'src/dtos/search.dto'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { ISearchRepository } from 'src/interfaces/search.interface'; import { SearchService } from 'src/services/search.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { personStub } from 'test/fixtures/person.stub'; -import { newTestService } from 'test/utils'; -import { Mocked, beforeEach, vitest } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; +import { beforeEach, vitest } from 'vitest'; vitest.useFakeTimers(); describe(SearchService.name, () => { let sut: SearchService; - - let assetMock: Mocked; - let personMock: Mocked; - let searchMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, assetMock, personMock, searchMock } = newTestService(SearchService)); + ({ sut, mocks } = newTestService(SearchService)); }); it('should work', () => { @@ -31,23 +25,25 @@ describe(SearchService.name, () => { it('should pass options to search', async () => { const { name } = personStub.withName; + mocks.person.getByName.mockResolvedValue([]); + await sut.searchPerson(authStub.user1, { name, withHidden: false }); - expect(personMock.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: false }); + expect(mocks.person.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: false }); await sut.searchPerson(authStub.user1, { name, withHidden: true }); - expect(personMock.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: true }); + expect(mocks.person.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: true }); }); }); describe('getExploreData', () => { it('should get assets by city and tag', async () => { - assetMock.getAssetIdByCity.mockResolvedValue({ + mocks.asset.getAssetIdByCity.mockResolvedValue({ fieldName: 'exifInfo.city', items: [{ value: 'test-city', data: assetStub.withLocation.id }], }); - assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.withLocation]); + mocks.asset.getByIdsWithAllRelations.mockResolvedValue([assetStub.withLocation]); const expectedResponse = [ { fieldName: 'exifInfo.city', items: [{ value: 'test-city', data: mapAsset(assetStub.withLocation) }] }, ]; @@ -60,83 +56,103 @@ describe(SearchService.name, () => { describe('getSearchSuggestions', () => { it('should return search suggestions for country', async () => { - searchMock.getCountries.mockResolvedValue(['USA']); + mocks.search.getCountries.mockResolvedValue(['USA']); + mocks.partner.getAll.mockResolvedValue([]); + await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.COUNTRY }), ).resolves.toEqual(['USA']); - expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); + expect(mocks.search.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); }); it('should return search suggestions for country (including null)', async () => { - searchMock.getCountries.mockResolvedValue(['USA']); + mocks.search.getCountries.mockResolvedValue(['USA']); + mocks.partner.getAll.mockResolvedValue([]); + await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.COUNTRY }), ).resolves.toEqual(['USA', null]); - expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); + expect(mocks.search.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); }); it('should return search suggestions for state', async () => { - searchMock.getStates.mockResolvedValue(['California']); + mocks.search.getStates.mockResolvedValue(['California']); + mocks.partner.getAll.mockResolvedValue([]); + await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.STATE }), ).resolves.toEqual(['California']); - expect(searchMock.getStates).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + expect(mocks.search.getStates).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); }); it('should return search suggestions for state (including null)', async () => { - searchMock.getStates.mockResolvedValue(['California']); + mocks.search.getStates.mockResolvedValue(['California']); + mocks.partner.getAll.mockResolvedValue([]); + await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.STATE }), ).resolves.toEqual(['California', null]); - expect(searchMock.getStates).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + expect(mocks.search.getStates).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); }); it('should return search suggestions for city', async () => { - searchMock.getCities.mockResolvedValue(['Denver']); + mocks.search.getCities.mockResolvedValue(['Denver']); + mocks.partner.getAll.mockResolvedValue([]); + await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CITY }), ).resolves.toEqual(['Denver']); - expect(searchMock.getCities).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + expect(mocks.search.getCities).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); }); it('should return search suggestions for city (including null)', async () => { - searchMock.getCities.mockResolvedValue(['Denver']); + mocks.search.getCities.mockResolvedValue(['Denver']); + mocks.partner.getAll.mockResolvedValue([]); + await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CITY }), ).resolves.toEqual(['Denver', null]); - expect(searchMock.getCities).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + expect(mocks.search.getCities).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); }); it('should return search suggestions for camera make', async () => { - searchMock.getCameraMakes.mockResolvedValue(['Nikon']); + mocks.search.getCameraMakes.mockResolvedValue(['Nikon']); + mocks.partner.getAll.mockResolvedValue([]); + await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CAMERA_MAKE }), ).resolves.toEqual(['Nikon']); - expect(searchMock.getCameraMakes).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + expect(mocks.search.getCameraMakes).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); }); it('should return search suggestions for camera make (including null)', async () => { - searchMock.getCameraMakes.mockResolvedValue(['Nikon']); + mocks.search.getCameraMakes.mockResolvedValue(['Nikon']); + mocks.partner.getAll.mockResolvedValue([]); + await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CAMERA_MAKE }), ).resolves.toEqual(['Nikon', null]); - expect(searchMock.getCameraMakes).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + expect(mocks.search.getCameraMakes).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); }); it('should return search suggestions for camera model', async () => { - searchMock.getCameraModels.mockResolvedValue(['Fujifilm X100VI']); + mocks.search.getCameraModels.mockResolvedValue(['Fujifilm X100VI']); + mocks.partner.getAll.mockResolvedValue([]); + await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CAMERA_MODEL }), ).resolves.toEqual(['Fujifilm X100VI']); - expect(searchMock.getCameraModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + expect(mocks.search.getCameraModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); }); it('should return search suggestions for camera model (including null)', async () => { - searchMock.getCameraModels.mockResolvedValue(['Fujifilm X100VI']); + mocks.search.getCameraModels.mockResolvedValue(['Fujifilm X100VI']); + mocks.partner.getAll.mockResolvedValue([]); + await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CAMERA_MODEL }), ).resolves.toEqual(['Fujifilm X100VI', null]); - expect(searchMock.getCameraModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + expect(mocks.search.getCameraModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); }); }); }); diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index b833d0184c..e2ad9e7f99 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -1,8 +1,9 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { AssetMapOptions, AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { PersonResponseDto } from 'src/dtos/person.dto'; +import { mapPerson, PersonResponseDto } from 'src/dtos/person.dto'; import { + mapPlaces, MetadataSearchDto, PlacesResponseDto, RandomSearchDto, @@ -12,11 +13,10 @@ import { SearchSuggestionRequestDto, SearchSuggestionType, SmartSearchDto, - mapPlaces, } from 'src/dtos/search.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetOrder } from 'src/enum'; -import { SearchExploreItem } from 'src/interfaces/search.interface'; +import { SearchExploreItem } from 'src/repositories/search.repository'; import { BaseService } from 'src/services/base.service'; import { getMyPartnerIds } from 'src/utils/asset.util'; import { isSmartSearchEnabled } from 'src/utils/misc'; @@ -24,7 +24,8 @@ import { isSmartSearchEnabled } from 'src/utils/misc'; @Injectable() export class SearchService extends BaseService { async searchPerson(auth: AuthDto, dto: SearchPeopleDto): Promise { - return this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden }); + const people = await this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden }); + return people.map((person) => mapPerson(person)); } async searchPlaces(dto: SearchPlacesDto): Promise { @@ -108,7 +109,7 @@ export class SearchService extends BaseService { return suggestions; } - private getSuggestions(userIds: string[], dto: SearchSuggestionRequestDto) { + private getSuggestions(userIds: string[], dto: SearchSuggestionRequestDto): Promise> { switch (dto.type) { case SearchSuggestionType.COUNTRY: { return this.searchRepository.getCountries(userIds); @@ -126,7 +127,7 @@ export class SearchService extends BaseService { return this.searchRepository.getCameraModels(userIds, dto); } default: { - return [] as (string | null)[]; + return Promise.resolve([]); } } } diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index 3f7fafcebf..05ebda6a94 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -1,20 +1,13 @@ import { SystemMetadataKey } from 'src/enum'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; import { ServerService } from 'src/services/server.service'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(ServerService.name, () => { let sut: ServerService; - - let storageMock: Mocked; - let systemMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, storageMock, systemMock, userMock } = newTestService(ServerService)); + ({ sut, mocks } = newTestService(ServerService)); }); it('should work', () => { @@ -23,7 +16,7 @@ describe(ServerService.name, () => { describe('getStorage', () => { it('should return the disk space as B', async () => { - storageMock.checkDiskUsage.mockResolvedValue({ free: 200, available: 300, total: 500 }); + mocks.storage.checkDiskUsage.mockResolvedValue({ free: 200, available: 300, total: 500 }); await expect(sut.getStorage()).resolves.toEqual({ diskAvailable: '300 B', @@ -35,11 +28,11 @@ describe(ServerService.name, () => { diskUseRaw: 300, }); - expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); }); it('should return the disk space as KiB', async () => { - storageMock.checkDiskUsage.mockResolvedValue({ free: 200_000, available: 300_000, total: 500_000 }); + mocks.storage.checkDiskUsage.mockResolvedValue({ free: 200_000, available: 300_000, total: 500_000 }); await expect(sut.getStorage()).resolves.toEqual({ diskAvailable: '293.0 KiB', @@ -51,11 +44,11 @@ describe(ServerService.name, () => { diskUseRaw: 300_000, }); - expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); }); it('should return the disk space as MiB', async () => { - storageMock.checkDiskUsage.mockResolvedValue({ free: 200_000_000, available: 300_000_000, total: 500_000_000 }); + mocks.storage.checkDiskUsage.mockResolvedValue({ free: 200_000_000, available: 300_000_000, total: 500_000_000 }); await expect(sut.getStorage()).resolves.toEqual({ diskAvailable: '286.1 MiB', @@ -67,11 +60,11 @@ describe(ServerService.name, () => { diskUseRaw: 300_000_000, }); - expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); }); it('should return the disk space as GiB', async () => { - storageMock.checkDiskUsage.mockResolvedValue({ + mocks.storage.checkDiskUsage.mockResolvedValue({ free: 200_000_000_000, available: 300_000_000_000, total: 500_000_000_000, @@ -87,11 +80,11 @@ describe(ServerService.name, () => { diskUseRaw: 300_000_000_000, }); - expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); }); it('should return the disk space as TiB', async () => { - storageMock.checkDiskUsage.mockResolvedValue({ + mocks.storage.checkDiskUsage.mockResolvedValue({ free: 200_000_000_000_000, available: 300_000_000_000_000, total: 500_000_000_000_000, @@ -107,11 +100,11 @@ describe(ServerService.name, () => { diskUseRaw: 300_000_000_000_000, }); - expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); }); it('should return the disk space as PiB', async () => { - storageMock.checkDiskUsage.mockResolvedValue({ + mocks.storage.checkDiskUsage.mockResolvedValue({ free: 200_000_000_000_000_000, available: 300_000_000_000_000_000, total: 500_000_000_000_000_000, @@ -127,7 +120,7 @@ describe(ServerService.name, () => { diskUseRaw: 300_000_000_000_000_000, }); - expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); }); }); @@ -155,7 +148,7 @@ describe(ServerService.name, () => { trash: true, email: false, }); - expect(systemMock.get).toHaveBeenCalled(); + expect(mocks.systemMetadata.get).toHaveBeenCalled(); }); }); @@ -173,13 +166,13 @@ describe(ServerService.name, () => { mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', }); - expect(systemMock.get).toHaveBeenCalled(); + expect(mocks.systemMetadata.get).toHaveBeenCalled(); }); }); describe('getStats', () => { it('should total up usage by user', async () => { - userMock.getUserStats.mockResolvedValue([ + mocks.user.getUserStats.mockResolvedValue([ { userId: 'user1', userName: '1 User', @@ -252,36 +245,36 @@ describe(ServerService.name, () => { ], }); - expect(userMock.getUserStats).toHaveBeenCalled(); + expect(mocks.user.getUserStats).toHaveBeenCalled(); }); }); describe('setLicense', () => { it('should save license if valid', async () => { - systemMock.set.mockResolvedValue(); + mocks.systemMetadata.set.mockResolvedValue(); const license = { licenseKey: 'IMSV-license-key', activationKey: 'activation-key' }; await sut.setLicense(license); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.LICENSE, expect.any(Object)); + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.LICENSE, expect.any(Object)); }); it('should not save license if invalid', async () => { - userMock.upsertMetadata.mockResolvedValue(); + mocks.user.upsertMetadata.mockResolvedValue(); const license = { licenseKey: 'license-key', activationKey: 'activation-key' }; const call = sut.setLicense(license); await expect(call).rejects.toThrowError('Invalid license key'); - expect(userMock.upsertMetadata).not.toHaveBeenCalled(); + expect(mocks.user.upsertMetadata).not.toHaveBeenCalled(); }); }); describe('deleteLicense', () => { it('should delete license', async () => { - userMock.upsertMetadata.mockResolvedValue(); + mocks.user.upsertMetadata.mockResolvedValue(); await sut.deleteLicense(); - expect(userMock.upsertMetadata).not.toHaveBeenCalled(); + expect(mocks.user.upsertMetadata).not.toHaveBeenCalled(); }); }); }); diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index e9dd908a7c..9112c40a17 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -14,7 +14,7 @@ import { UsageByUserDto, } from 'src/dtos/server.dto'; import { StorageFolder, SystemMetadataKey } from 'src/enum'; -import { UserStatsQueryResponse } from 'src/interfaces/user.interface'; +import { UserStatsQueryResponse } from 'src/repositories/user.repository'; import { BaseService } from 'src/services/base.service'; import { asHumanReadable } from 'src/utils/bytes'; import { mimeTypes } from 'src/utils/mime-types'; diff --git a/server/src/services/session.service.spec.ts b/server/src/services/session.service.spec.ts index 49d1227712..3d1b09a39d 100644 --- a/server/src/services/session.service.spec.ts +++ b/server/src/services/session.service.spec.ts @@ -1,21 +1,15 @@ -import { UserEntity } from 'src/entities/user.entity'; -import { JobStatus } from 'src/interfaces/job.interface'; -import { ISessionRepository } from 'src/interfaces/session.interface'; +import { JobStatus } from 'src/enum'; import { SessionService } from 'src/services/session.service'; import { authStub } from 'test/fixtures/auth.stub'; import { sessionStub } from 'test/fixtures/session.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe('SessionService', () => { let sut: SessionService; - - let accessMock: Mocked; - let sessionMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, sessionMock } = newTestService(SessionService)); + ({ sut, mocks } = newTestService(SessionService)); }); it('should be defined', () => { @@ -24,13 +18,13 @@ describe('SessionService', () => { describe('handleCleanup', () => { it('should return skipped if nothing is to be deleted', async () => { - sessionMock.search.mockResolvedValue([]); + mocks.session.search.mockResolvedValue([]); await expect(sut.handleCleanup()).resolves.toEqual(JobStatus.SKIPPED); - expect(sessionMock.search).toHaveBeenCalled(); + expect(mocks.session.search).toHaveBeenCalled(); }); it('should delete sessions', async () => { - sessionMock.search.mockResolvedValue([ + mocks.session.search.mockResolvedValue([ { createdAt: new Date('1970-01-01T00:00:00.00Z'), updatedAt: new Date('1970-01-02T00:00:00.00Z'), @@ -38,19 +32,20 @@ describe('SessionService', () => { deviceType: '', id: '123', token: '420', - user: {} as UserEntity, userId: '42', + updateId: 'uuid-v7', }, ]); + mocks.session.delete.mockResolvedValue(); await expect(sut.handleCleanup()).resolves.toEqual(JobStatus.SUCCESS); - expect(sessionMock.delete).toHaveBeenCalledWith('123'); + expect(mocks.session.delete).toHaveBeenCalledWith('123'); }); }); describe('getAll', () => { it('should get the devices', async () => { - sessionMock.getByUserId.mockResolvedValue([sessionStub.valid, sessionStub.inactive]); + mocks.session.getByUserId.mockResolvedValue([sessionStub.valid as any, sessionStub.inactive]); await expect(sut.getAll(authStub.user1)).resolves.toEqual([ { createdAt: '2021-01-01T00:00:00.000Z', @@ -70,30 +65,35 @@ describe('SessionService', () => { }, ]); - expect(sessionMock.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id); + expect(mocks.session.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id); }); }); describe('logoutDevices', () => { it('should logout all devices', async () => { - sessionMock.getByUserId.mockResolvedValue([sessionStub.inactive, sessionStub.valid]); + mocks.session.getByUserId.mockResolvedValue([sessionStub.inactive, sessionStub.valid] as any[]); + mocks.session.delete.mockResolvedValue(); await sut.deleteAll(authStub.user1); - expect(sessionMock.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id); - expect(sessionMock.delete).toHaveBeenCalledWith('not_active'); - expect(sessionMock.delete).not.toHaveBeenCalledWith('token-id'); + expect(mocks.session.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id); + expect(mocks.session.delete).toHaveBeenCalledWith('not_active'); + expect(mocks.session.delete).not.toHaveBeenCalledWith('token-id'); }); }); describe('logoutDevice', () => { it('should logout the device', async () => { - accessMock.authDevice.checkOwnerAccess.mockResolvedValue(new Set(['token-1'])); + mocks.access.authDevice.checkOwnerAccess.mockResolvedValue(new Set(['token-1'])); + mocks.session.delete.mockResolvedValue(); await sut.delete(authStub.user1, 'token-1'); - expect(accessMock.authDevice.checkOwnerAccess).toHaveBeenCalledWith(authStub.user1.user.id, new Set(['token-1'])); - expect(sessionMock.delete).toHaveBeenCalledWith('token-1'); + expect(mocks.access.authDevice.checkOwnerAccess).toHaveBeenCalledWith( + authStub.user1.user.id, + new Set(['token-1']), + ); + expect(mocks.session.delete).toHaveBeenCalledWith('token-1'); }); }); }); diff --git a/server/src/services/session.service.ts b/server/src/services/session.service.ts index 68df7828ad..6b0632cd44 100644 --- a/server/src/services/session.service.ts +++ b/server/src/services/session.service.ts @@ -3,8 +3,7 @@ import { DateTime } from 'luxon'; import { OnJob } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { SessionResponseDto, mapSession } from 'src/dtos/session.dto'; -import { Permission } from 'src/enum'; -import { JobName, JobStatus, QueueName } from 'src/interfaces/job.interface'; +import { JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { BaseService } from 'src/services/base.service'; @Injectable() diff --git a/server/src/services/shared-link.service.spec.ts b/server/src/services/shared-link.service.spec.ts index 2d673eb7ca..557fdd5780 100644 --- a/server/src/services/shared-link.service.spec.ts +++ b/server/src/services/shared-link.service.spec.ts @@ -2,24 +2,19 @@ import { BadRequestException, ForbiddenException, UnauthorizedException } from ' import _ from 'lodash'; import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { SharedLinkType } from 'src/enum'; -import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { SharedLinkService } from 'src/services/shared-link.service'; import { albumStub } from 'test/fixtures/album.stub'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { sharedLinkResponseStub, sharedLinkStub } from 'test/fixtures/shared-link.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(SharedLinkService.name, () => { let sut: SharedLinkService; - - let accessMock: IAccessRepositoryMock; - let sharedLinkMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, sharedLinkMock } = newTestService(SharedLinkService)); + ({ sut, mocks } = newTestService(SharedLinkService)); }); it('should work', () => { @@ -28,46 +23,46 @@ describe(SharedLinkService.name, () => { describe('getAll', () => { it('should return all shared links for a user', async () => { - sharedLinkMock.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]); - await expect(sut.getAll(authStub.user1)).resolves.toEqual([ + mocks.sharedLink.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]); + await expect(sut.getAll(authStub.user1, {})).resolves.toEqual([ sharedLinkResponseStub.expired, sharedLinkResponseStub.valid, ]); - expect(sharedLinkMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id); + expect(mocks.sharedLink.getAll).toHaveBeenCalledWith({ userId: authStub.user1.user.id }); }); }); describe('getMine', () => { it('should only work for a public user', async () => { await expect(sut.getMine(authStub.admin, {})).rejects.toBeInstanceOf(ForbiddenException); - expect(sharedLinkMock.get).not.toHaveBeenCalled(); + expect(mocks.sharedLink.get).not.toHaveBeenCalled(); }); it('should return the shared link for the public user', async () => { const authDto = authStub.adminSharedLink; - sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.valid); - expect(sharedLinkMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); }); it('should not return metadata', async () => { const authDto = authStub.adminSharedLinkNoExif; - sharedLinkMock.get.mockResolvedValue(sharedLinkStub.readonlyNoExif); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.readonlyNoExif); await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata); - expect(sharedLinkMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); }); it('should throw an error for an invalid password protected shared link', async () => { const authDto = authStub.adminSharedLink; - sharedLinkMock.get.mockResolvedValue(sharedLinkStub.passwordRequired); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.passwordRequired); await expect(sut.getMine(authDto, {})).rejects.toBeInstanceOf(UnauthorizedException); - expect(sharedLinkMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); }); it('should allow a correct password on a password protected shared link', async () => { - sharedLinkMock.get.mockResolvedValue({ ...sharedLinkStub.individual, password: '123' }); + mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, password: '123' }); await expect(sut.getMine(authStub.adminSharedLink, { password: '123' })).resolves.toBeDefined(); - expect(sharedLinkMock.get).toHaveBeenCalledWith( + expect(mocks.sharedLink.get).toHaveBeenCalledWith( authStub.adminSharedLink.user.id, authStub.adminSharedLink.sharedLink?.id, ); @@ -76,15 +71,18 @@ describe(SharedLinkService.name, () => { describe('get', () => { it('should throw an error for an invalid shared link', async () => { + mocks.sharedLink.get.mockResolvedValue(void 0); + await expect(sut.get(authStub.user1, 'missing-id')).rejects.toBeInstanceOf(BadRequestException); - expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); - expect(sharedLinkMock.update).not.toHaveBeenCalled(); + + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); + expect(mocks.sharedLink.update).not.toHaveBeenCalled(); }); it('should get a shared link by id', async () => { - sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); await expect(sut.get(authStub.user1, sharedLinkStub.valid.id)).resolves.toEqual(sharedLinkResponseStub.valid); - expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); }); }); @@ -114,16 +112,16 @@ describe(SharedLinkService.name, () => { }); it('should create an album shared link', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id])); - sharedLinkMock.create.mockResolvedValue(sharedLinkStub.valid); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id])); + mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.valid); await sut.create(authStub.admin, { type: SharedLinkType.ALBUM, albumId: albumStub.oneAsset.id }); - expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith( + expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([albumStub.oneAsset.id]), ); - expect(sharedLinkMock.create).toHaveBeenCalledWith({ + expect(mocks.sharedLink.create).toHaveBeenCalledWith({ type: SharedLinkType.ALBUM, userId: authStub.admin.user.id, albumId: albumStub.oneAsset.id, @@ -137,8 +135,8 @@ describe(SharedLinkService.name, () => { }); it('should create an individual shared link', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); await sut.create(authStub.admin, { type: SharedLinkType.INDIVIDUAL, @@ -148,11 +146,11 @@ describe(SharedLinkService.name, () => { allowUpload: true, }); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith( + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([assetStub.image.id]), ); - expect(sharedLinkMock.create).toHaveBeenCalledWith({ + expect(mocks.sharedLink.create).toHaveBeenCalledWith({ type: SharedLinkType.INDIVIDUAL, userId: authStub.admin.user.id, albumId: null, @@ -167,8 +165,8 @@ describe(SharedLinkService.name, () => { }); it('should create a shared link with allowDownload set to false when showMetadata is false', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); await sut.create(authStub.admin, { type: SharedLinkType.INDIVIDUAL, @@ -178,11 +176,11 @@ describe(SharedLinkService.name, () => { allowUpload: true, }); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith( + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([assetStub.image.id]), ); - expect(sharedLinkMock.create).toHaveBeenCalledWith({ + expect(mocks.sharedLink.create).toHaveBeenCalledWith({ type: SharedLinkType.INDIVIDUAL, userId: authStub.admin.user.id, albumId: null, @@ -199,17 +197,20 @@ describe(SharedLinkService.name, () => { describe('update', () => { it('should throw an error for an invalid shared link', async () => { + mocks.sharedLink.get.mockResolvedValue(void 0); + await expect(sut.update(authStub.user1, 'missing-id', {})).rejects.toBeInstanceOf(BadRequestException); - expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); - expect(sharedLinkMock.update).not.toHaveBeenCalled(); + + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); + expect(mocks.sharedLink.update).not.toHaveBeenCalled(); }); it('should update a shared link', async () => { - sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); - sharedLinkMock.update.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.valid); await sut.update(authStub.user1, sharedLinkStub.valid.id, { allowDownload: false }); - expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); - expect(sharedLinkMock.update).toHaveBeenCalledWith({ + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); + expect(mocks.sharedLink.update).toHaveBeenCalledWith({ id: sharedLinkStub.valid.id, userId: authStub.user1.user.id, allowDownload: false, @@ -219,31 +220,38 @@ describe(SharedLinkService.name, () => { describe('remove', () => { it('should throw an error for an invalid shared link', async () => { + mocks.sharedLink.get.mockResolvedValue(void 0); + await expect(sut.remove(authStub.user1, 'missing-id')).rejects.toBeInstanceOf(BadRequestException); - expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); - expect(sharedLinkMock.update).not.toHaveBeenCalled(); + + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); + expect(mocks.sharedLink.update).not.toHaveBeenCalled(); }); it('should remove a key', async () => { - sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.remove.mockResolvedValue(); + await sut.remove(authStub.user1, sharedLinkStub.valid.id); - expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); - expect(sharedLinkMock.remove).toHaveBeenCalledWith(sharedLinkStub.valid); + + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); + expect(mocks.sharedLink.remove).toHaveBeenCalledWith(sharedLinkStub.valid); }); }); describe('addAssets', () => { it('should not work on album shared links', async () => { - sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); await expect(sut.addAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf( BadRequestException, ); }); it('should add assets to a shared link', async () => { - sharedLinkMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); - sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-3'])); + mocks.sharedLink.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); + mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); + mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.individual); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-3'])); await expect( sut.addAssets(authStub.admin, 'link-1', { assetIds: [assetStub.image.id, 'asset-2', 'asset-3'] }), @@ -253,9 +261,9 @@ describe(SharedLinkService.name, () => { { assetId: 'asset-3', success: true }, ]); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledTimes(1); - expect(sharedLinkMock.update).toHaveBeenCalled(); - expect(sharedLinkMock.update).toHaveBeenCalledWith({ + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledTimes(1); + expect(mocks.sharedLink.update).toHaveBeenCalled(); + expect(mocks.sharedLink.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assetIds: ['asset-3'], }); @@ -264,15 +272,16 @@ describe(SharedLinkService.name, () => { describe('removeAssets', () => { it('should not work on album shared links', async () => { - sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); await expect(sut.removeAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf( BadRequestException, ); }); it('should remove assets from a shared link', async () => { - sharedLinkMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); - sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual); + mocks.sharedLink.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); + mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); + mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.individual); await expect( sut.removeAssets(authStub.admin, 'link-1', { assetIds: [assetStub.image.id, 'asset-2'] }), @@ -281,39 +290,39 @@ describe(SharedLinkService.name, () => { { assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND }, ]); - expect(sharedLinkMock.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [] }); + expect(mocks.sharedLink.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [] }); }); }); describe('getMetadataTags', () => { it('should return null when auth is not a shared link', async () => { await expect(sut.getMetadataTags(authStub.admin)).resolves.toBe(null); - expect(sharedLinkMock.get).not.toHaveBeenCalled(); + expect(mocks.sharedLink.get).not.toHaveBeenCalled(); }); it('should return null when shared link has a password', async () => { await expect(sut.getMetadataTags(authStub.passwordSharedLink)).resolves.toBe(null); - expect(sharedLinkMock.get).not.toHaveBeenCalled(); + expect(mocks.sharedLink.get).not.toHaveBeenCalled(); }); it('should return metadata tags', async () => { - sharedLinkMock.get.mockResolvedValue(sharedLinkStub.individual); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.individual); await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({ description: '1 shared photos & videos', imageUrl: `http://localhost:2283/api/assets/asset-id/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0`, title: 'Public Share', }); - expect(sharedLinkMock.get).toHaveBeenCalled(); + expect(mocks.sharedLink.get).toHaveBeenCalled(); }); it('should return metadata tags with a default image path if the asset id is not set', async () => { - sharedLinkMock.get.mockResolvedValue({ ...sharedLinkStub.individual, album: undefined, assets: [] }); + mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, album: undefined, assets: [] }); await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({ description: '0 shared photos & videos', imageUrl: `http://localhost:2283/feature-panel.png`, title: 'Public Share', }); - expect(sharedLinkMock.get).toHaveBeenCalled(); + expect(mocks.sharedLink.get).toHaveBeenCalled(); }); }); }); diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index a015bbe3f3..74595bb9a2 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -9,6 +9,7 @@ import { SharedLinkEditDto, SharedLinkPasswordDto, SharedLinkResponseDto, + SharedLinkSearchDto, } from 'src/dtos/shared-link.dto'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { Permission, SharedLinkType } from 'src/enum'; @@ -17,8 +18,10 @@ import { getExternalDomain, OpenGraphTags } from 'src/utils/misc'; @Injectable() export class SharedLinkService extends BaseService { - async getAll(auth: AuthDto): Promise { - return this.sharedLinkRepository.getAll(auth.user.id).then((links) => links.map((link) => mapSharedLink(link))); + async getAll(auth: AuthDto, { albumId }: SharedLinkSearchDto): Promise { + return this.sharedLinkRepository + .getAll({ userId: auth.user.id, albumId }) + .then((links) => links.map((link) => mapSharedLink(link))); } async getMine(auth: AuthDto, dto: SharedLinkPasswordDto): Promise { diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index ff0dcc3160..aef83a813d 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -1,36 +1,21 @@ import { SystemConfig } from 'src/config'; -import { ImmichWorker } from 'src/enum'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { IDatabaseRepository } from 'src/interfaces/database.interface'; -import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; -import { ISearchRepository } from 'src/interfaces/search.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { ImmichWorker, JobName, JobStatus } from 'src/enum'; +import { WithoutProperty } from 'src/repositories/asset.repository'; import { SmartInfoService } from 'src/services/smart-info.service'; -import { IConfigRepository } from 'src/types'; import { getCLIPModelInfo } from 'src/utils/misc'; import { assetStub } from 'test/fixtures/asset.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(SmartInfoService.name, () => { let sut: SmartInfoService; - - let assetMock: Mocked; - let databaseMock: Mocked; - let jobMock: Mocked; - let machineLearningMock: Mocked; - let searchMock: Mocked; - let systemMock: Mocked; - let configMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, assetMock, databaseMock, jobMock, machineLearningMock, searchMock, systemMock, configMock } = - newTestService(SmartInfoService)); + ({ sut, mocks } = newTestService(SmartInfoService)); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - configMock.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.config.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); }); it('should work', () => { @@ -70,79 +55,79 @@ describe(SmartInfoService.name, () => { it('should return if machine learning is disabled', async () => { await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningDisabled as SystemConfig }); - expect(searchMock.getDimensionSize).not.toHaveBeenCalled(); - expect(searchMock.setDimensionSize).not.toHaveBeenCalled(); - expect(searchMock.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); - expect(jobMock.getQueueStatus).not.toHaveBeenCalled(); - expect(jobMock.pause).not.toHaveBeenCalled(); - expect(jobMock.waitForQueueCompletion).not.toHaveBeenCalled(); - expect(jobMock.resume).not.toHaveBeenCalled(); + expect(mocks.search.getDimensionSize).not.toHaveBeenCalled(); + expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); + expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); + expect(mocks.job.getQueueStatus).not.toHaveBeenCalled(); + expect(mocks.job.pause).not.toHaveBeenCalled(); + expect(mocks.job.waitForQueueCompletion).not.toHaveBeenCalled(); + expect(mocks.job.resume).not.toHaveBeenCalled(); }); it('should return if model and DB dimension size are equal', async () => { - searchMock.getDimensionSize.mockResolvedValue(512); + mocks.search.getDimensionSize.mockResolvedValue(512); await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningEnabled as SystemConfig }); - expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); - expect(searchMock.setDimensionSize).not.toHaveBeenCalled(); - expect(searchMock.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); - expect(jobMock.getQueueStatus).not.toHaveBeenCalled(); - expect(jobMock.pause).not.toHaveBeenCalled(); - expect(jobMock.waitForQueueCompletion).not.toHaveBeenCalled(); - expect(jobMock.resume).not.toHaveBeenCalled(); + expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); + expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); + expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); + expect(mocks.job.getQueueStatus).not.toHaveBeenCalled(); + expect(mocks.job.pause).not.toHaveBeenCalled(); + expect(mocks.job.waitForQueueCompletion).not.toHaveBeenCalled(); + expect(mocks.job.resume).not.toHaveBeenCalled(); }); it('should update DB dimension size if model and DB have different values', async () => { - searchMock.getDimensionSize.mockResolvedValue(768); - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.search.getDimensionSize.mockResolvedValue(768); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningEnabled as SystemConfig }); - expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); - expect(searchMock.setDimensionSize).toHaveBeenCalledWith(512); - expect(jobMock.getQueueStatus).toHaveBeenCalledTimes(1); - expect(jobMock.pause).toHaveBeenCalledTimes(1); - expect(jobMock.waitForQueueCompletion).toHaveBeenCalledTimes(1); - expect(jobMock.resume).toHaveBeenCalledTimes(1); + expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); + expect(mocks.search.setDimensionSize).toHaveBeenCalledWith(512); + expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1); + expect(mocks.job.pause).toHaveBeenCalledTimes(1); + expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1); + expect(mocks.job.resume).toHaveBeenCalledTimes(1); }); it('should skip pausing and resuming queue if already paused', async () => { - searchMock.getDimensionSize.mockResolvedValue(768); - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true }); + mocks.search.getDimensionSize.mockResolvedValue(768); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true }); await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningEnabled as SystemConfig }); - expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); - expect(searchMock.setDimensionSize).toHaveBeenCalledWith(512); - expect(jobMock.getQueueStatus).toHaveBeenCalledTimes(1); - expect(jobMock.pause).not.toHaveBeenCalled(); - expect(jobMock.waitForQueueCompletion).toHaveBeenCalledTimes(1); - expect(jobMock.resume).not.toHaveBeenCalled(); + expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); + expect(mocks.search.setDimensionSize).toHaveBeenCalledWith(512); + expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1); + expect(mocks.job.pause).not.toHaveBeenCalled(); + expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1); + expect(mocks.job.resume).not.toHaveBeenCalled(); }); }); describe('onConfigUpdateEvent', () => { it('should return if machine learning is disabled', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); await sut.onConfigUpdate({ newConfig: systemConfigStub.machineLearningDisabled as SystemConfig, oldConfig: systemConfigStub.machineLearningDisabled as SystemConfig, }); - expect(systemMock.get).not.toHaveBeenCalled(); - expect(searchMock.getDimensionSize).not.toHaveBeenCalled(); - expect(searchMock.setDimensionSize).not.toHaveBeenCalled(); - expect(searchMock.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); - expect(jobMock.getQueueStatus).not.toHaveBeenCalled(); - expect(jobMock.pause).not.toHaveBeenCalled(); - expect(jobMock.waitForQueueCompletion).not.toHaveBeenCalled(); - expect(jobMock.resume).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).not.toHaveBeenCalled(); + expect(mocks.search.getDimensionSize).not.toHaveBeenCalled(); + expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); + expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); + expect(mocks.job.getQueueStatus).not.toHaveBeenCalled(); + expect(mocks.job.pause).not.toHaveBeenCalled(); + expect(mocks.job.waitForQueueCompletion).not.toHaveBeenCalled(); + expect(mocks.job.resume).not.toHaveBeenCalled(); }); it('should return if model and DB dimension size are equal', async () => { - searchMock.getDimensionSize.mockResolvedValue(512); + mocks.search.getDimensionSize.mockResolvedValue(512); await sut.onConfigUpdate({ newConfig: { @@ -153,18 +138,18 @@ describe(SmartInfoService.name, () => { } as SystemConfig, }); - expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); - expect(searchMock.setDimensionSize).not.toHaveBeenCalled(); - expect(searchMock.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); - expect(jobMock.getQueueStatus).not.toHaveBeenCalled(); - expect(jobMock.pause).not.toHaveBeenCalled(); - expect(jobMock.waitForQueueCompletion).not.toHaveBeenCalled(); - expect(jobMock.resume).not.toHaveBeenCalled(); + expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); + expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); + expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); + expect(mocks.job.getQueueStatus).not.toHaveBeenCalled(); + expect(mocks.job.pause).not.toHaveBeenCalled(); + expect(mocks.job.waitForQueueCompletion).not.toHaveBeenCalled(); + expect(mocks.job.resume).not.toHaveBeenCalled(); }); it('should update DB dimension size if model and DB have different values', async () => { - searchMock.getDimensionSize.mockResolvedValue(512); - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.search.getDimensionSize.mockResolvedValue(512); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.onConfigUpdate({ newConfig: { @@ -175,17 +160,17 @@ describe(SmartInfoService.name, () => { } as SystemConfig, }); - expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); - expect(searchMock.setDimensionSize).toHaveBeenCalledWith(768); - expect(jobMock.getQueueStatus).toHaveBeenCalledTimes(1); - expect(jobMock.pause).toHaveBeenCalledTimes(1); - expect(jobMock.waitForQueueCompletion).toHaveBeenCalledTimes(1); - expect(jobMock.resume).toHaveBeenCalledTimes(1); + expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); + expect(mocks.search.setDimensionSize).toHaveBeenCalledWith(768); + expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1); + expect(mocks.job.pause).toHaveBeenCalledTimes(1); + expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1); + expect(mocks.job.resume).toHaveBeenCalledTimes(1); }); it('should clear embeddings if old and new models are different', async () => { - searchMock.getDimensionSize.mockResolvedValue(512); - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.search.getDimensionSize.mockResolvedValue(512); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await sut.onConfigUpdate({ newConfig: { @@ -196,18 +181,18 @@ describe(SmartInfoService.name, () => { } as SystemConfig, }); - expect(searchMock.deleteAllSearchEmbeddings).toHaveBeenCalled(); - expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); - expect(searchMock.setDimensionSize).not.toHaveBeenCalled(); - expect(jobMock.getQueueStatus).toHaveBeenCalledTimes(1); - expect(jobMock.pause).toHaveBeenCalledTimes(1); - expect(jobMock.waitForQueueCompletion).toHaveBeenCalledTimes(1); - expect(jobMock.resume).toHaveBeenCalledTimes(1); + expect(mocks.search.deleteAllSearchEmbeddings).toHaveBeenCalled(); + expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); + expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); + expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1); + expect(mocks.job.pause).toHaveBeenCalledTimes(1); + expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1); + expect(mocks.job.resume).toHaveBeenCalledTimes(1); }); it('should skip pausing and resuming queue if already paused', async () => { - searchMock.getDimensionSize.mockResolvedValue(512); - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true }); + mocks.search.getDimensionSize.mockResolvedValue(512); + mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true }); await sut.onConfigUpdate({ newConfig: { @@ -218,115 +203,119 @@ describe(SmartInfoService.name, () => { } as SystemConfig, }); - expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); - expect(searchMock.setDimensionSize).not.toHaveBeenCalled(); - expect(jobMock.getQueueStatus).toHaveBeenCalledTimes(1); - expect(jobMock.pause).not.toHaveBeenCalled(); - expect(jobMock.waitForQueueCompletion).toHaveBeenCalledTimes(1); - expect(jobMock.resume).not.toHaveBeenCalled(); + expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); + expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); + expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1); + expect(mocks.job.pause).not.toHaveBeenCalled(); + expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1); + expect(mocks.job.resume).not.toHaveBeenCalled(); }); }); describe('handleQueueEncodeClip', () => { it('should do nothing if machine learning is disabled', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); await sut.handleQueueEncodeClip({}); - expect(assetMock.getAll).not.toHaveBeenCalled(); - expect(assetMock.getWithout).not.toHaveBeenCalled(); + expect(mocks.asset.getAll).not.toHaveBeenCalled(); + expect(mocks.asset.getWithout).not.toHaveBeenCalled(); }); it('should queue the assets without clip embeddings', async () => { - assetMock.getWithout.mockResolvedValue({ + mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); await sut.handleQueueEncodeClip({ force: false }); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } }]); - expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.SMART_SEARCH); - expect(searchMock.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } }, + ]); + expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.SMART_SEARCH); + expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); }); it('should queue all the assets', async () => { - assetMock.getAll.mockResolvedValue({ + mocks.asset.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, }); await sut.handleQueueEncodeClip({ force: true }); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } }]); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(searchMock.deleteAllSearchEmbeddings).toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } }, + ]); + expect(mocks.asset.getAll).toHaveBeenCalled(); + expect(mocks.search.deleteAllSearchEmbeddings).toHaveBeenCalled(); }); }); describe('handleEncodeClip', () => { it('should do nothing if machine learning is disabled', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); expect(await sut.handleEncodeClip({ id: '123' })).toEqual(JobStatus.SKIPPED); - expect(assetMock.getByIds).not.toHaveBeenCalled(); - expect(machineLearningMock.encodeImage).not.toHaveBeenCalled(); + expect(mocks.asset.getByIds).not.toHaveBeenCalled(); + expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled(); }); it('should skip assets without a resize path', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]); + mocks.asset.getByIds.mockResolvedValue([assetStub.noResizePath]); expect(await sut.handleEncodeClip({ id: assetStub.noResizePath.id })).toEqual(JobStatus.FAILED); - expect(searchMock.upsert).not.toHaveBeenCalled(); - expect(machineLearningMock.encodeImage).not.toHaveBeenCalled(); + expect(mocks.search.upsert).not.toHaveBeenCalled(); + expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled(); }); it('should save the returned objects', async () => { - machineLearningMock.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]'); + mocks.machineLearning.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]'); expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS); - expect(machineLearningMock.encodeImage).toHaveBeenCalledWith( + expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith( ['http://immich-machine-learning:3003'], '/uploads/user-id/thumbs/path.jpg', expect.objectContaining({ modelName: 'ViT-B-32__openai' }), ); - expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, '[0.01, 0.02, 0.03]'); + expect(mocks.search.upsert).toHaveBeenCalledWith(assetStub.image.id, '[0.01, 0.02, 0.03]'); }); it('should skip invisible assets', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); expect(await sut.handleEncodeClip({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); - expect(machineLearningMock.encodeImage).not.toHaveBeenCalled(); - expect(searchMock.upsert).not.toHaveBeenCalled(); + expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled(); + expect(mocks.search.upsert).not.toHaveBeenCalled(); }); it('should fail if asset could not be found', async () => { - assetMock.getByIds.mockResolvedValue([]); + mocks.asset.getByIds.mockResolvedValue([]); expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.FAILED); - expect(machineLearningMock.encodeImage).not.toHaveBeenCalled(); - expect(searchMock.upsert).not.toHaveBeenCalled(); + expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled(); + expect(mocks.search.upsert).not.toHaveBeenCalled(); }); it('should wait for database', async () => { - machineLearningMock.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]'); - databaseMock.isBusy.mockReturnValue(true); + mocks.machineLearning.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]'); + mocks.database.isBusy.mockReturnValue(true); expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS); - expect(databaseMock.wait).toHaveBeenCalledWith(512); - expect(machineLearningMock.encodeImage).toHaveBeenCalledWith( + expect(mocks.database.wait).toHaveBeenCalledWith(512); + expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith( ['http://immich-machine-learning:3003'], '/uploads/user-id/thumbs/path.jpg', expect.objectContaining({ modelName: 'ViT-B-32__openai' }), ); - expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, '[0.01, 0.02, 0.03]'); + expect(mocks.search.upsert).toHaveBeenCalledWith(assetStub.image.id, '[0.01, 0.02, 0.03]'); }); }); diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index 8fef961fe1..063bb0bd3b 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 { ImmichWorker } from 'src/enum'; -import { WithoutProperty } from 'src/interfaces/asset.interface'; -import { DatabaseLock } from 'src/interfaces/database.interface'; -import { ArgOf } from 'src/interfaces/event.interface'; -import { JOBS_ASSET_PAGINATION_SIZE, JobName, JobOf, JobStatus, QueueName } from 'src/interfaces/job.interface'; +import { DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum'; +import { WithoutProperty } from 'src/repositories/asset.repository'; +import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; +import { JobOf } from 'src/types'; import { getAssetFiles } from 'src/utils/asset.util'; import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; diff --git a/server/src/services/stack.service.spec.ts b/server/src/services/stack.service.spec.ts index f37e2c4af4..f6da8bcac7 100644 --- a/server/src/services/stack.service.spec.ts +++ b/server/src/services/stack.service.spec.ts @@ -1,22 +1,15 @@ import { BadRequestException } from '@nestjs/common'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { IStackRepository } from 'src/interfaces/stack.interface'; import { StackService } from 'src/services/stack.service'; import { assetStub, stackStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(StackService.name, () => { let sut: StackService; - - let accessMock: IAccessRepositoryMock; - let eventMock: Mocked; - let stackMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, eventMock, stackMock } = newTestService(StackService)); + ({ sut, mocks } = newTestService(StackService)); }); it('should be defined', () => { @@ -25,10 +18,10 @@ describe(StackService.name, () => { describe('search', () => { it('should search stacks', async () => { - stackMock.search.mockResolvedValue([stackStub('stack-id', [assetStub.image])]); + mocks.stack.search.mockResolvedValue([stackStub('stack-id', [assetStub.image])]); await sut.search(authStub.admin, { primaryAssetId: assetStub.image.id }); - expect(stackMock.search).toHaveBeenCalledWith({ + expect(mocks.stack.search).toHaveBeenCalledWith({ ownerId: authStub.admin.user.id, primaryAssetId: assetStub.image.id, }); @@ -41,13 +34,13 @@ describe(StackService.name, () => { sut.create(authStub.admin, { assetIds: [assetStub.image.id, assetStub.image1.id] }), ).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalled(); - expect(stackMock.create).not.toHaveBeenCalled(); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalled(); + expect(mocks.stack.create).not.toHaveBeenCalled(); }); it('should create a stack', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id, assetStub.image1.id])); - stackMock.create.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id, assetStub.image1.id])); + mocks.stack.create.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); await expect( sut.create(authStub.admin, { assetIds: [assetStub.image.id, assetStub.image1.id] }), ).resolves.toEqual({ @@ -59,11 +52,11 @@ describe(StackService.name, () => { ], }); - expect(eventMock.emit).toHaveBeenCalledWith('stack.create', { + expect(mocks.event.emit).toHaveBeenCalledWith('stack.create', { stackId: 'stack-id', userId: authStub.admin.user.id, }); - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalled(); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalled(); }); }); @@ -71,22 +64,22 @@ describe(StackService.name, () => { it('should require stack.read permissions', async () => { await expect(sut.get(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.stack.checkOwnerAccess).toHaveBeenCalled(); - expect(stackMock.getById).not.toHaveBeenCalled(); + expect(mocks.access.stack.checkOwnerAccess).toHaveBeenCalled(); + expect(mocks.stack.getById).not.toHaveBeenCalled(); }); it('should fail if stack could not be found', async () => { - accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); await expect(sut.get(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(Error); - expect(accessMock.stack.checkOwnerAccess).toHaveBeenCalled(); - expect(stackMock.getById).toHaveBeenCalledWith('stack-id'); + expect(mocks.access.stack.checkOwnerAccess).toHaveBeenCalled(); + expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); }); it('should get stack', async () => { - accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); - stackMock.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); await expect(sut.get(authStub.admin, 'stack-id')).resolves.toEqual({ id: 'stack-id', @@ -96,8 +89,8 @@ describe(StackService.name, () => { expect.objectContaining({ id: assetStub.image1.id }), ], }); - expect(accessMock.stack.checkOwnerAccess).toHaveBeenCalled(); - expect(stackMock.getById).toHaveBeenCalledWith('stack-id'); + expect(mocks.access.stack.checkOwnerAccess).toHaveBeenCalled(); + expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); }); }); @@ -105,47 +98,47 @@ describe(StackService.name, () => { it('should require stack.update permissions', async () => { await expect(sut.update(authStub.admin, 'stack-id', {})).rejects.toBeInstanceOf(BadRequestException); - expect(stackMock.getById).not.toHaveBeenCalled(); - expect(stackMock.update).not.toHaveBeenCalled(); - expect(eventMock.emit).not.toHaveBeenCalled(); + expect(mocks.stack.getById).not.toHaveBeenCalled(); + expect(mocks.stack.update).not.toHaveBeenCalled(); + expect(mocks.event.emit).not.toHaveBeenCalled(); }); it('should fail if stack could not be found', async () => { - accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); await expect(sut.update(authStub.admin, 'stack-id', {})).rejects.toBeInstanceOf(Error); - expect(stackMock.getById).toHaveBeenCalledWith('stack-id'); - expect(stackMock.update).not.toHaveBeenCalled(); - expect(eventMock.emit).not.toHaveBeenCalled(); + expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); + expect(mocks.stack.update).not.toHaveBeenCalled(); + expect(mocks.event.emit).not.toHaveBeenCalled(); }); it('should fail if the provided primary asset id is not in the stack', async () => { - accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); - stackMock.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); await expect(sut.update(authStub.admin, 'stack-id', { primaryAssetId: 'unknown-asset' })).rejects.toBeInstanceOf( BadRequestException, ); - expect(stackMock.getById).toHaveBeenCalledWith('stack-id'); - expect(stackMock.update).not.toHaveBeenCalled(); - expect(eventMock.emit).not.toHaveBeenCalled(); + expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); + expect(mocks.stack.update).not.toHaveBeenCalled(); + expect(mocks.event.emit).not.toHaveBeenCalled(); }); it('should update stack', async () => { - accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); - stackMock.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); - stackMock.update.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); + mocks.stack.update.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); await sut.update(authStub.admin, 'stack-id', { primaryAssetId: assetStub.image1.id }); - expect(stackMock.getById).toHaveBeenCalledWith('stack-id'); - expect(stackMock.update).toHaveBeenCalledWith('stack-id', { + expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); + expect(mocks.stack.update).toHaveBeenCalledWith('stack-id', { id: 'stack-id', primaryAssetId: assetStub.image1.id, }); - expect(eventMock.emit).toHaveBeenCalledWith('stack.update', { + expect(mocks.event.emit).toHaveBeenCalledWith('stack.update', { stackId: 'stack-id', userId: authStub.admin.user.id, }); @@ -156,17 +149,18 @@ describe(StackService.name, () => { it('should require stack.delete permissions', async () => { await expect(sut.delete(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(BadRequestException); - expect(stackMock.delete).not.toHaveBeenCalled(); - expect(eventMock.emit).not.toHaveBeenCalled(); + expect(mocks.stack.delete).not.toHaveBeenCalled(); + expect(mocks.event.emit).not.toHaveBeenCalled(); }); it('should delete stack', async () => { - accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + mocks.stack.delete.mockResolvedValue(); await sut.delete(authStub.admin, 'stack-id'); - expect(stackMock.delete).toHaveBeenCalledWith('stack-id'); - expect(eventMock.emit).toHaveBeenCalledWith('stack.delete', { + expect(mocks.stack.delete).toHaveBeenCalledWith('stack-id'); + expect(mocks.event.emit).toHaveBeenCalledWith('stack.delete', { stackId: 'stack-id', userId: authStub.admin.user.id, }); @@ -177,17 +171,18 @@ describe(StackService.name, () => { it('should require stack.delete permissions', async () => { await expect(sut.deleteAll(authStub.admin, { ids: ['stack-id'] })).rejects.toBeInstanceOf(BadRequestException); - expect(stackMock.deleteAll).not.toHaveBeenCalled(); - expect(eventMock.emit).not.toHaveBeenCalled(); + expect(mocks.stack.deleteAll).not.toHaveBeenCalled(); + expect(mocks.event.emit).not.toHaveBeenCalled(); }); it('should delete all stacks', async () => { - accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + mocks.stack.deleteAll.mockResolvedValue(); await sut.deleteAll(authStub.admin, { ids: ['stack-id'] }); - expect(stackMock.deleteAll).toHaveBeenCalledWith(['stack-id']); - expect(eventMock.emit).toHaveBeenCalledWith('stacks.delete', { + expect(mocks.stack.deleteAll).toHaveBeenCalledWith(['stack-id']); + expect(mocks.event.emit).toHaveBeenCalledWith('stacks.delete', { stackIds: ['stack-id'], userId: authStub.admin.user.id, }); diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index 46ec4f53e1..6bfabe2e8c 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -1,42 +1,27 @@ import { Stats } from 'node:fs'; -import { SystemConfig, defaults } from 'src/config'; -import { AssetEntity } from 'src/entities/asset.entity'; -import { AssetPathType } from 'src/enum'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { JobStatus } from 'src/interfaces/job.interface'; -import { IMoveRepository } from 'src/interfaces/move.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { defaults, SystemConfig } from 'src/config'; +import { AssetPathType, JobStatus } from 'src/enum'; import { StorageTemplateService } from 'src/services/storage-template.service'; import { albumStub } from 'test/fixtures/album.stub'; import { assetStub } from 'test/fixtures/asset.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { makeStream, newTestService, ServiceMocks } from 'test/utils'; + +const motionAsset = assetStub.storageAsset({}); +const stillAsset = assetStub.storageAsset({ livePhotoVideoId: motionAsset.id }); describe(StorageTemplateService.name, () => { let sut: StorageTemplateService; - - let albumMock: Mocked; - let assetMock: Mocked; - let cryptoMock: Mocked; - let moveMock: Mocked; - let storageMock: Mocked; - let systemMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; it('should work', () => { expect(sut).toBeDefined(); }); beforeEach(() => { - ({ sut, albumMock, assetMock, cryptoMock, moveMock, storageMock, systemMock, userMock } = - newTestService(StorageTemplateService)); + ({ sut, mocks } = newTestService(StorageTemplateService)); - systemMock.get.mockResolvedValue({ storageTemplate: { enabled: true } }); + mocks.systemMetadata.get.mockResolvedValue({ storageTemplate: { enabled: true } }); sut.onConfigInit({ newConfig: defaults }); }); @@ -107,65 +92,53 @@ describe(StorageTemplateService.name, () => { describe('handleMigrationSingle', () => { it('should skip when storage template is disabled', async () => { - systemMock.get.mockResolvedValue({ storageTemplate: { enabled: false } }); - await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SKIPPED); - expect(assetMock.getByIds).not.toHaveBeenCalled(); - expect(storageMock.checkFileExists).not.toHaveBeenCalled(); - expect(storageMock.rename).not.toHaveBeenCalled(); - expect(storageMock.copyFile).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalled(); - expect(moveMock.create).not.toHaveBeenCalled(); - expect(moveMock.update).not.toHaveBeenCalled(); - expect(storageMock.stat).not.toHaveBeenCalled(); + mocks.systemMetadata.get.mockResolvedValue({ storageTemplate: { enabled: false } }); + + await expect(sut.handleMigrationSingle({ id: testAsset.id })).resolves.toBe(JobStatus.SKIPPED); + + expect(mocks.asset.getByIds).not.toHaveBeenCalled(); + expect(mocks.storage.checkFileExists).not.toHaveBeenCalled(); + expect(mocks.storage.rename).not.toHaveBeenCalled(); + expect(mocks.storage.copyFile).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); + expect(mocks.move.create).not.toHaveBeenCalled(); + expect(mocks.move.update).not.toHaveBeenCalled(); + expect(mocks.storage.stat).not.toHaveBeenCalled(); }); it('should migrate single moving picture', async () => { - userMock.get.mockResolvedValue(userStub.user1); - const newMotionPicturePath = `upload/library/${userStub.user1.id}/2022/2022-06-19/${assetStub.livePhotoStillAsset.id}.mp4`; - const newStillPicturePath = `upload/library/${userStub.user1.id}/2022/2022-06-19/${assetStub.livePhotoStillAsset.id}.jpeg`; + mocks.user.get.mockResolvedValue(userStub.user1); + const newMotionPicturePath = `upload/library/${motionAsset.ownerId}/2022/2022-06-19/${motionAsset.originalFileName}`; + const newStillPicturePath = `upload/library/${stillAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName}`; - assetMock.getByIds.mockImplementation((ids) => { - const assets = [assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]; - return Promise.resolve( - ids.map((id) => assets.find((asset) => asset.id === id)).filter((asset) => !!asset), - ) as Promise; - }); + mocks.asset.getStorageTemplateAsset.mockResolvedValueOnce(stillAsset); + mocks.asset.getStorageTemplateAsset.mockResolvedValueOnce(motionAsset); - moveMock.create.mockResolvedValueOnce({ + mocks.move.create.mockResolvedValueOnce({ id: '123', - entityId: assetStub.livePhotoStillAsset.id, + entityId: stillAsset.id, pathType: AssetPathType.ORIGINAL, - oldPath: assetStub.livePhotoStillAsset.originalPath, + oldPath: stillAsset.originalPath, newPath: newStillPicturePath, }); - moveMock.create.mockResolvedValueOnce({ + mocks.move.create.mockResolvedValueOnce({ id: '124', - entityId: assetStub.livePhotoMotionAsset.id, + entityId: motionAsset.id, pathType: AssetPathType.ORIGINAL, - oldPath: assetStub.livePhotoMotionAsset.originalPath, + oldPath: motionAsset.originalPath, newPath: newMotionPicturePath, }); - await expect(sut.handleMigrationSingle({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( - JobStatus.SUCCESS, - ); + await expect(sut.handleMigrationSingle({ id: stillAsset.id })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id], { exifInfo: true }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { exifInfo: true }); - expect(storageMock.checkFileExists).toHaveBeenCalledTimes(2); - expect(assetMock.update).toHaveBeenCalledWith({ - id: assetStub.livePhotoStillAsset.id, - originalPath: newStillPicturePath, - }); - expect(assetMock.update).toHaveBeenCalledWith({ - id: assetStub.livePhotoMotionAsset.id, - originalPath: newMotionPicturePath, - }); + expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(2); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: stillAsset.id, originalPath: newStillPicturePath }); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, originalPath: newMotionPicturePath }); }); it('should use handlebar if condition for album', async () => { - const asset = assetStub.image; + const asset = assetStub.storageAsset(); const user = userStub.user1; const album = albumStub.oneAsset; const config = structuredClone(defaults); @@ -173,13 +146,13 @@ describe(StorageTemplateService.name, () => { sut.onConfigInit({ newConfig: config }); - userMock.get.mockResolvedValue(user); - assetMock.getByIds.mockResolvedValueOnce([asset]); - albumMock.getByAssetId.mockResolvedValueOnce([album]); + mocks.user.get.mockResolvedValue(user); + mocks.asset.getStorageTemplateAsset.mockResolvedValueOnce(asset); + mocks.album.getByAssetId.mockResolvedValueOnce([album]); expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.SUCCESS); - expect(moveMock.create).toHaveBeenCalledWith({ + expect(mocks.move.create).toHaveBeenCalledWith({ entityId: asset.id, newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${album.albumName}/${asset.originalFileName}`, oldPath: asset.originalPath, @@ -188,19 +161,19 @@ describe(StorageTemplateService.name, () => { }); it('should use handlebar else condition for album', async () => { - const asset = assetStub.image; + const asset = assetStub.storageAsset(); const user = userStub.user1; const config = structuredClone(defaults); config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other//{{MM}}{{/if}}/{{filename}}'; sut.onConfigInit({ newConfig: config }); - userMock.get.mockResolvedValue(user); - assetMock.getByIds.mockResolvedValueOnce([asset]); + mocks.user.get.mockResolvedValue(user); + mocks.asset.getStorageTemplateAsset.mockResolvedValueOnce(asset); expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.SUCCESS); const month = (asset.fileCreatedAt.getMonth() + 1).toString().padStart(2, '0'); - expect(moveMock.create).toHaveBeenCalledWith({ + expect(mocks.move.create).toHaveBeenCalledWith({ entityId: asset.id, newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/other/${month}/${asset.originalFileName}`, oldPath: asset.originalPath, @@ -209,400 +182,370 @@ describe(StorageTemplateService.name, () => { }); it('should migrate previously failed move from original path when it still exists', async () => { - userMock.get.mockResolvedValue(userStub.user1); - const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`; - const newPath = `upload/library/${userStub.user1.id}/2023/2023-02-23/${assetStub.image.id}.jpg`; + mocks.user.get.mockResolvedValue(userStub.user1); - storageMock.checkFileExists.mockImplementation((path) => Promise.resolve(path === assetStub.image.originalPath)); - moveMock.getByEntity.mockResolvedValue({ + const asset = assetStub.storageAsset(); + const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${asset.originalFileName}`; + const newPath = `upload/library/${userStub.user1.id}/2022/2022-06-19/${asset.originalFileName}`; + + mocks.storage.checkFileExists.mockImplementation((path) => Promise.resolve(path === asset.originalPath)); + mocks.move.getByEntity.mockResolvedValue({ id: '123', - entityId: assetStub.image.id, + entityId: asset.id, pathType: AssetPathType.ORIGINAL, - oldPath: assetStub.image.originalPath, + oldPath: asset.originalPath, newPath: previousFailedNewPath, }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - moveMock.update.mockResolvedValue({ + mocks.asset.getStorageTemplateAsset.mockResolvedValue(asset); + mocks.move.update.mockResolvedValue({ id: '123', - entityId: assetStub.image.id, + entityId: asset.id, pathType: AssetPathType.ORIGINAL, - oldPath: assetStub.image.originalPath, + oldPath: asset.originalPath, newPath, }); - await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleMigrationSingle({ id: asset.id })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); - expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3); - expect(storageMock.rename).toHaveBeenCalledWith(assetStub.image.originalPath, newPath); - expect(moveMock.update).toHaveBeenCalledWith('123', { + expect(mocks.asset.getStorageTemplateAsset).toHaveBeenCalledWith(asset.id); + expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(3); + expect(mocks.storage.rename).toHaveBeenCalledWith(asset.originalPath, newPath); + expect(mocks.move.update).toHaveBeenCalledWith('123', { id: '123', - oldPath: assetStub.image.originalPath, + oldPath: asset.originalPath, newPath, }); - expect(assetMock.update).toHaveBeenCalledWith({ - id: assetStub.image.id, + expect(mocks.asset.update).toHaveBeenCalledWith({ + id: asset.id, originalPath: newPath, }); }); it('should migrate previously failed move from previous new path when old path no longer exists, should validate file size still matches before moving', async () => { - userMock.get.mockResolvedValue(userStub.user1); - const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`; - const newPath = `upload/library/${userStub.user1.id}/2023/2023-02-23/${assetStub.image.id}.jpg`; + mocks.user.get.mockResolvedValue(userStub.user1); - storageMock.checkFileExists.mockImplementation((path) => Promise.resolve(path === previousFailedNewPath)); - storageMock.stat.mockResolvedValue({ size: 5000 } as Stats); - cryptoMock.hashFile.mockResolvedValue(assetStub.image.checksum); - moveMock.getByEntity.mockResolvedValue({ + const asset = assetStub.storageAsset({ fileSizeInByte: 5000 }); + const previousFailedNewPath = `upload/library/${asset.ownerId}/2022/June/${asset.originalFileName}`; + const newPath = `upload/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`; + + mocks.storage.checkFileExists.mockImplementation((path) => Promise.resolve(path === previousFailedNewPath)); + mocks.storage.stat.mockResolvedValue({ size: 5000 } as Stats); + mocks.crypto.hashFile.mockResolvedValue(asset.checksum); + mocks.move.getByEntity.mockResolvedValue({ id: '123', - entityId: assetStub.image.id, + entityId: asset.id, pathType: AssetPathType.ORIGINAL, - oldPath: assetStub.image.originalPath, + oldPath: asset.originalPath, newPath: previousFailedNewPath, }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - moveMock.update.mockResolvedValue({ + mocks.asset.getStorageTemplateAsset.mockResolvedValue(asset); + mocks.move.update.mockResolvedValue({ id: '123', - entityId: assetStub.image.id, + entityId: asset.id, pathType: AssetPathType.ORIGINAL, oldPath: previousFailedNewPath, newPath, }); - await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleMigrationSingle({ id: asset.id })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); - expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3); - expect(storageMock.stat).toHaveBeenCalledWith(previousFailedNewPath); - expect(storageMock.rename).toHaveBeenCalledWith(previousFailedNewPath, newPath); - expect(storageMock.copyFile).not.toHaveBeenCalled(); - expect(moveMock.update).toHaveBeenCalledWith('123', { - id: '123', - oldPath: previousFailedNewPath, - newPath, - }); - expect(assetMock.update).toHaveBeenCalledWith({ - id: assetStub.image.id, - originalPath: newPath, - }); + expect(mocks.asset.getStorageTemplateAsset).toHaveBeenCalledWith(asset.id); + expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(3); + expect(mocks.storage.stat).toHaveBeenCalledWith(previousFailedNewPath); + expect(mocks.storage.rename).toHaveBeenCalledWith(previousFailedNewPath, newPath); + expect(mocks.storage.copyFile).not.toHaveBeenCalled(); + expect(mocks.move.update).toHaveBeenCalledWith('123', { id: '123', oldPath: previousFailedNewPath, newPath }); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, originalPath: newPath }); }); it('should fail move if copying and hash of asset and the new file do not match', async () => { - userMock.get.mockResolvedValue(userStub.user1); - const newPath = `upload/library/${userStub.user1.id}/2023/2023-02-23/${assetStub.image.id}.jpg`; + mocks.user.get.mockResolvedValue(userStub.user1); + const newPath = `upload/library/${userStub.user1.id}/2022/2022-06-19/${testAsset.originalFileName}`; - storageMock.rename.mockRejectedValue({ code: 'EXDEV' }); - storageMock.stat.mockResolvedValue({ size: 5000 } as Stats); - cryptoMock.hashFile.mockResolvedValue(Buffer.from('different-hash', 'utf8')); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - moveMock.create.mockResolvedValue({ + mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' }); + mocks.storage.stat.mockResolvedValue({ size: 5000 } as Stats); + mocks.crypto.hashFile.mockResolvedValue(Buffer.from('different-hash', 'utf8')); + mocks.asset.getStorageTemplateAsset.mockResolvedValue(testAsset); + mocks.move.create.mockResolvedValue({ id: '123', - entityId: assetStub.image.id, + entityId: testAsset.id, pathType: AssetPathType.ORIGINAL, - oldPath: assetStub.image.originalPath, + oldPath: testAsset.originalPath, newPath, }); - await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleMigrationSingle({ id: testAsset.id })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); - expect(storageMock.checkFileExists).toHaveBeenCalledTimes(1); - expect(storageMock.stat).toHaveBeenCalledWith(newPath); - expect(moveMock.create).toHaveBeenCalledWith({ - entityId: assetStub.image.id, + expect(mocks.asset.getStorageTemplateAsset).toHaveBeenCalledWith(testAsset.id); + expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(1); + expect(mocks.storage.stat).toHaveBeenCalledWith(newPath); + expect(mocks.move.create).toHaveBeenCalledWith({ + entityId: testAsset.id, pathType: AssetPathType.ORIGINAL, - oldPath: assetStub.image.originalPath, + oldPath: testAsset.originalPath, newPath, }); - expect(storageMock.rename).toHaveBeenCalledWith(assetStub.image.originalPath, newPath); - expect(storageMock.copyFile).toHaveBeenCalledWith(assetStub.image.originalPath, newPath); - expect(storageMock.unlink).toHaveBeenCalledWith(newPath); - expect(storageMock.unlink).toHaveBeenCalledTimes(1); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.storage.rename).toHaveBeenCalledWith(testAsset.originalPath, newPath); + expect(mocks.storage.copyFile).toHaveBeenCalledWith(testAsset.originalPath, newPath); + expect(mocks.storage.unlink).toHaveBeenCalledWith(newPath); + expect(mocks.storage.unlink).toHaveBeenCalledTimes(1); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); + const testAsset = assetStub.storageAsset(); + it.each` - failedPathChecksum | failedPathSize | reason - ${assetStub.image.checksum} | ${500} | ${'file size'} - ${Buffer.from('bad checksum', 'utf8')} | ${assetStub.image.exifInfo?.fileSizeInByte} | ${'checksum'} + failedPathChecksum | failedPathSize | reason + ${testAsset.checksum} | ${500} | ${'file size'} + ${Buffer.from('bad checksum', 'utf8')} | ${testAsset.fileSizeInByte} | ${'checksum'} `( 'should fail to migrate previously failed move from previous new path when old path no longer exists if $reason validation fails', async ({ failedPathChecksum, failedPathSize }) => { - userMock.get.mockResolvedValue(userStub.user1); - const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`; - const newPath = `upload/library/${userStub.user1.id}/2023/2023-02-23/${assetStub.image.id}.jpg`; + mocks.user.get.mockResolvedValue(userStub.user1); + const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${testAsset.originalFileName}`; + const newPath = `upload/library/${userStub.user1.id}/2023/2023-02-23/${testAsset.originalFileName}`; - storageMock.checkFileExists.mockImplementation((path) => Promise.resolve(previousFailedNewPath === path)); - storageMock.stat.mockResolvedValue({ size: failedPathSize } as Stats); - cryptoMock.hashFile.mockResolvedValue(failedPathChecksum); - moveMock.getByEntity.mockResolvedValue({ + mocks.storage.checkFileExists.mockImplementation((path) => Promise.resolve(previousFailedNewPath === path)); + mocks.storage.stat.mockResolvedValue({ size: failedPathSize } as Stats); + mocks.crypto.hashFile.mockResolvedValue(failedPathChecksum); + mocks.move.getByEntity.mockResolvedValue({ id: '123', - entityId: assetStub.image.id, + entityId: testAsset.id, pathType: AssetPathType.ORIGINAL, - oldPath: assetStub.image.originalPath, + oldPath: testAsset.originalPath, newPath: previousFailedNewPath, }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - moveMock.update.mockResolvedValue({ + mocks.asset.getStorageTemplateAsset.mockResolvedValue(testAsset); + mocks.move.update.mockResolvedValue({ id: '123', - entityId: assetStub.image.id, + entityId: testAsset.id, pathType: AssetPathType.ORIGINAL, oldPath: previousFailedNewPath, newPath, }); - await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleMigrationSingle({ id: testAsset.id })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); - expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3); - expect(storageMock.stat).toHaveBeenCalledWith(previousFailedNewPath); - expect(storageMock.rename).not.toHaveBeenCalled(); - expect(storageMock.copyFile).not.toHaveBeenCalled(); - expect(moveMock.update).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.getStorageTemplateAsset).toHaveBeenCalledWith(testAsset.id); + expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(3); + expect(mocks.storage.stat).toHaveBeenCalledWith(previousFailedNewPath); + expect(mocks.storage.rename).not.toHaveBeenCalled(); + expect(mocks.storage.copyFile).not.toHaveBeenCalled(); + expect(mocks.move.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); }, ); }); describe('handle template migration', () => { it('should handle no assets', async () => { - assetMock.getAll.mockResolvedValue({ - items: [], - hasNextPage: false, - }); - userMock.getList.mockResolvedValue([]); + mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([])); + mocks.user.getList.mockResolvedValue([]); await sut.handleMigration(); - expect(assetMock.getAll).toHaveBeenCalled(); + expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled(); }); it('should handle an asset with a duplicate destination', async () => { - assetMock.getAll.mockResolvedValue({ - items: [assetStub.image], - hasNextPage: false, - }); - userMock.getList.mockResolvedValue([userStub.user1]); - moveMock.create.mockResolvedValue({ + const asset = assetStub.storageAsset(); + const oldPath = asset.originalPath; + const newPath = `upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`; + const newPath2 = newPath.replace('.jpg', '+1.jpg'); + + mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset])); + mocks.user.getList.mockResolvedValue([userStub.user1]); + mocks.move.create.mockResolvedValue({ id: '123', - entityId: assetStub.image.id, + entityId: asset.id, pathType: AssetPathType.ORIGINAL, - oldPath: assetStub.image.originalPath, - newPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg', + oldPath, + newPath, }); - storageMock.checkFileExists.mockResolvedValueOnce(true); - storageMock.checkFileExists.mockResolvedValueOnce(false); + mocks.storage.checkFileExists.mockResolvedValueOnce(true); + mocks.storage.checkFileExists.mockResolvedValueOnce(false); await sut.handleMigration(); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(storageMock.checkFileExists).toHaveBeenCalledTimes(2); - expect(assetMock.update).toHaveBeenCalledWith({ - id: assetStub.image.id, - originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg', - }); - expect(userMock.getList).toHaveBeenCalled(); + expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled(); + expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(2); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, originalPath: newPath2 }); + expect(mocks.user.getList).toHaveBeenCalled(); }); it('should skip when an asset already matches the template', async () => { - assetMock.getAll.mockResolvedValue({ - items: [ - { - ...assetStub.image, - originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', - }, - ], - hasNextPage: false, - }); - userMock.getList.mockResolvedValue([userStub.user1]); + const asset = assetStub.storageAsset({ originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg' }); + + mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset])); + mocks.user.getList.mockResolvedValue([userStub.user1]); await sut.handleMigration(); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(storageMock.rename).not.toHaveBeenCalled(); - expect(storageMock.copyFile).not.toHaveBeenCalled(); - expect(storageMock.checkFileExists).not.toHaveBeenCalledTimes(2); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled(); + expect(mocks.storage.rename).not.toHaveBeenCalled(); + expect(mocks.storage.copyFile).not.toHaveBeenCalled(); + expect(mocks.storage.checkFileExists).not.toHaveBeenCalledTimes(2); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should skip when an asset is probably a duplicate', async () => { - assetMock.getAll.mockResolvedValue({ - items: [ - { - ...assetStub.image, - originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg', - }, - ], - hasNextPage: false, - }); - userMock.getList.mockResolvedValue([userStub.user1]); + const asset = assetStub.storageAsset({ originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg' }); + + mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset])); + mocks.user.getList.mockResolvedValue([userStub.user1]); await sut.handleMigration(); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(storageMock.rename).not.toHaveBeenCalled(); - expect(storageMock.copyFile).not.toHaveBeenCalled(); - expect(storageMock.checkFileExists).not.toHaveBeenCalledTimes(2); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled(); + expect(mocks.storage.rename).not.toHaveBeenCalled(); + expect(mocks.storage.copyFile).not.toHaveBeenCalled(); + expect(mocks.storage.checkFileExists).not.toHaveBeenCalledTimes(2); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should move an asset', async () => { - assetMock.getAll.mockResolvedValue({ - items: [assetStub.image], - hasNextPage: false, - }); - userMock.getList.mockResolvedValue([userStub.user1]); - moveMock.create.mockResolvedValue({ - id: '123', - entityId: assetStub.image.id, - pathType: AssetPathType.ORIGINAL, - oldPath: assetStub.image.originalPath, - newPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', - }); - - await sut.handleMigration(); - - expect(assetMock.getAll).toHaveBeenCalled(); - expect(storageMock.rename).toHaveBeenCalledWith( - '/original/path.jpg', - 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', - ); - expect(assetMock.update).toHaveBeenCalledWith({ - id: assetStub.image.id, - originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', - }); - }); - - it('should use the user storage label', async () => { - assetMock.getAll.mockResolvedValue({ - items: [assetStub.image], - hasNextPage: false, - }); - userMock.getList.mockResolvedValue([userStub.storageLabel]); - moveMock.create.mockResolvedValue({ - id: '123', - entityId: assetStub.image.id, - pathType: AssetPathType.ORIGINAL, - oldPath: assetStub.image.originalPath, - newPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', - }); - - await sut.handleMigration(); - - expect(assetMock.getAll).toHaveBeenCalled(); - expect(storageMock.rename).toHaveBeenCalledWith( - '/original/path.jpg', - 'upload/library/label-1/2023/2023-02-23/asset-id.jpg', - ); - expect(assetMock.update).toHaveBeenCalledWith({ - id: assetStub.image.id, - originalPath: 'upload/library/label-1/2023/2023-02-23/asset-id.jpg', - }); - }); - - it('should copy the file if rename fails due to EXDEV (rename across filesystems)', async () => { - const newPath = 'upload/library/user-id/2023/2023-02-23/asset-id.jpg'; - assetMock.getAll.mockResolvedValue({ - items: [assetStub.image], - hasNextPage: false, - }); - storageMock.rename.mockRejectedValue({ code: 'EXDEV' }); - userMock.getList.mockResolvedValue([userStub.user1]); - moveMock.create.mockResolvedValue({ + const asset = assetStub.storageAsset(); + const oldPath = asset.originalPath; + const newPath = `upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`; + mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset])); + mocks.user.getList.mockResolvedValue([userStub.user1]); + mocks.move.create.mockResolvedValue({ id: '123', entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, oldPath: assetStub.image.originalPath, newPath, }); - storageMock.stat.mockResolvedValueOnce({ - atime: new Date(), - mtime: new Date(), - } as Stats); - storageMock.stat.mockResolvedValueOnce({ - size: 5000, - } as Stats); - storageMock.stat.mockResolvedValueOnce({ - atime: new Date(), - mtime: new Date(), - } as Stats); - cryptoMock.hashFile.mockResolvedValue(assetStub.image.checksum); await sut.handleMigration(); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(storageMock.rename).toHaveBeenCalledWith('/original/path.jpg', newPath); - expect(storageMock.copyFile).toHaveBeenCalledWith('/original/path.jpg', newPath); - expect(storageMock.stat).toHaveBeenCalledWith(newPath); - expect(storageMock.stat).toHaveBeenCalledWith(assetStub.image.originalPath); - expect(storageMock.utimes).toHaveBeenCalledWith(newPath, expect.any(Date), expect.any(Date)); - expect(storageMock.unlink).toHaveBeenCalledWith(assetStub.image.originalPath); - expect(storageMock.unlink).toHaveBeenCalledTimes(1); - expect(assetMock.update).toHaveBeenCalledWith({ - id: assetStub.image.id, - originalPath: newPath, + expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled(); + expect(mocks.storage.rename).toHaveBeenCalledWith(oldPath, newPath); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, originalPath: newPath }); + }); + + it('should use the user storage label', async () => { + const asset = assetStub.storageAsset(); + mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset])); + mocks.user.getList.mockResolvedValue([userStub.storageLabel]); + mocks.move.create.mockResolvedValue({ + id: '123', + entityId: asset.id, + pathType: AssetPathType.ORIGINAL, + oldPath: asset.originalPath, + newPath: `upload/library/user-id/2023/2023-02-23/${asset.originalFileName}`, + }); + + await sut.handleMigration(); + + expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled(); + expect(mocks.storage.rename).toHaveBeenCalledWith( + '/original/path.jpg', + `upload/library/label-1/2022/2022-06-19/${asset.originalFileName}`, + ); + expect(mocks.asset.update).toHaveBeenCalledWith({ + id: asset.id, + originalPath: `upload/library/label-1/2022/2022-06-19/${asset.originalFileName}`, }); }); - it('should not update the database if the move fails due to incorrect newPath filesize', async () => { - assetMock.getAll.mockResolvedValue({ - items: [assetStub.image], - hasNextPage: false, - }); - storageMock.rename.mockRejectedValue({ code: 'EXDEV' }); - userMock.getList.mockResolvedValue([userStub.user1]); - moveMock.create.mockResolvedValue({ + it('should copy the file if rename fails due to EXDEV (rename across filesystems)', async () => { + const asset = assetStub.storageAsset({ originalPath: '/path/to/original.jpg', fileSizeInByte: 5000 }); + const oldPath = asset.originalPath; + const newPath = `upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`; + mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset])); + mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' }); + mocks.user.getList.mockResolvedValue([userStub.user1]); + mocks.move.create.mockResolvedValue({ id: '123', - entityId: assetStub.image.id, + entityId: asset.id, pathType: AssetPathType.ORIGINAL, - oldPath: assetStub.image.originalPath, - newPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', + oldPath, + newPath, }); - storageMock.stat.mockResolvedValue({ + mocks.storage.stat.mockResolvedValueOnce({ + atime: new Date(), + mtime: new Date(), + } as Stats); + mocks.storage.stat.mockResolvedValueOnce({ + size: 5000, + } as Stats); + mocks.storage.stat.mockResolvedValueOnce({ + size: 5000, + atime: new Date(), + mtime: new Date(), + } as Stats); + mocks.crypto.hashFile.mockResolvedValue(asset.checksum); + + await sut.handleMigration(); + + expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled(); + expect(mocks.storage.rename).toHaveBeenCalledWith(oldPath, newPath); + expect(mocks.storage.copyFile).toHaveBeenCalledWith(oldPath, newPath); + expect(mocks.storage.stat).toHaveBeenCalledWith(oldPath); + expect(mocks.storage.stat).toHaveBeenCalledWith(newPath); + expect(mocks.storage.utimes).toHaveBeenCalledWith(newPath, expect.any(Date), expect.any(Date)); + expect(mocks.storage.unlink).toHaveBeenCalledWith(oldPath); + expect(mocks.storage.unlink).toHaveBeenCalledTimes(1); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, originalPath: newPath }); + }); + + it('should not update the database if the move fails due to incorrect newPath filesize', async () => { + const asset = assetStub.storageAsset(); + mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset])); + mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' }); + mocks.user.getList.mockResolvedValue([userStub.user1]); + mocks.move.create.mockResolvedValue({ + id: '123', + entityId: asset.id, + pathType: AssetPathType.ORIGINAL, + oldPath: asset.originalPath, + newPath: `upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`, + }); + mocks.storage.stat.mockResolvedValue({ size: 100, } as Stats); await sut.handleMigration(); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(storageMock.rename).toHaveBeenCalledWith( + expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled(); + expect(mocks.storage.rename).toHaveBeenCalledWith( '/original/path.jpg', - 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', + `upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`, ); - expect(storageMock.copyFile).toHaveBeenCalledWith( + expect(mocks.storage.copyFile).toHaveBeenCalledWith( '/original/path.jpg', - 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', + `upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`, ); - expect(storageMock.stat).toHaveBeenCalledWith('upload/library/user-id/2023/2023-02-23/asset-id.jpg'); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.storage.stat).toHaveBeenCalledWith( + `upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`, + ); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should not update the database if the move fails', async () => { - assetMock.getAll.mockResolvedValue({ - items: [assetStub.image], - hasNextPage: false, - }); - storageMock.rename.mockRejectedValue(new Error('Read only system')); - storageMock.copyFile.mockRejectedValue(new Error('Read only system')); - moveMock.create.mockResolvedValue({ + const asset = assetStub.storageAsset(); + mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset])); + mocks.storage.rename.mockRejectedValue(new Error('Read only system')); + mocks.storage.copyFile.mockRejectedValue(new Error('Read only system')); + mocks.move.create.mockResolvedValue({ id: 'move-123', - entityId: '123', + entityId: asset.id, pathType: AssetPathType.ORIGINAL, - oldPath: assetStub.image.originalPath, + oldPath: asset.originalPath, newPath: '', }); - userMock.getList.mockResolvedValue([userStub.user1]); + mocks.user.getList.mockResolvedValue([userStub.user1]); await sut.handleMigration(); - expect(assetMock.getAll).toHaveBeenCalled(); - expect(storageMock.rename).toHaveBeenCalledWith( + expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled(); + expect(mocks.storage.rename).toHaveBeenCalledWith( '/original/path.jpg', - 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', + `upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`, ); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); }); }); }); diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index e8e4bd12a5..1a0d4f4644 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -6,14 +6,11 @@ import sanitize from 'sanitize-filename'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent, OnJob } from 'src/decorators'; import { SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto'; -import { AssetEntity } from 'src/entities/asset.entity'; -import { AssetPathType, AssetType, StorageFolder } from 'src/enum'; -import { DatabaseLock } from 'src/interfaces/database.interface'; -import { ArgOf } from 'src/interfaces/event.interface'; -import { JobName, JobOf, JOBS_ASSET_PAGINATION_SIZE, JobStatus, QueueName } from 'src/interfaces/job.interface'; +import { AssetPathType, AssetType, DatabaseLock, JobName, JobStatus, QueueName, StorageFolder } from 'src/enum'; +import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; +import { JobOf, StorageAsset } from 'src/types'; import { getLivePhotoMotionFilename } from 'src/utils/file'; -import { usePagination } from 'src/utils/pagination'; const storageTokens = { secondOptions: ['s', 'ss', 'SSS'], @@ -53,7 +50,7 @@ export interface MoveAssetMetadata { } interface RenderMetadata { - asset: AssetEntity; + asset: StorageAsset; filename: string; extension: string; albumName: string | null; @@ -98,7 +95,7 @@ export class StorageTemplateService extends BaseService { originalPath: '/upload/test/IMG_123.jpg', type: AssetType.IMAGE, id: 'd587e44b-f8c0-4832-9ba3-43268bbf5d4e', - } as AssetEntity, + } as StorageAsset, filename: 'IMG_123', extension: 'jpg', albumName: 'album', @@ -121,7 +118,7 @@ export class StorageTemplateService extends BaseService { return JobStatus.SKIPPED; } - const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true }); + const asset = await this.assetRepository.getStorageTemplateAsset(id); if (!asset) { return JobStatus.FAILED; } @@ -133,7 +130,7 @@ export class StorageTemplateService extends BaseService { // move motion part of live photo if (asset.livePhotoVideoId) { - const [livePhotoVideo] = await this.assetRepository.getByIds([asset.livePhotoVideoId], { exifInfo: true }); + const livePhotoVideo = await this.assetRepository.getStorageTemplateAsset(asset.livePhotoVideoId); if (!livePhotoVideo) { return JobStatus.FAILED; } @@ -152,18 +149,17 @@ export class StorageTemplateService extends BaseService { this.logger.log('Storage template migration disabled, skipping'); return JobStatus.SKIPPED; } - const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.assetRepository.getAll(pagination, { withExif: true, withArchived: true }), - ); + + await this.moveRepository.cleanMoveHistory(); + + const assets = this.assetRepository.streamStorageTemplateAssets(); const users = await this.userRepository.getList(); - for await (const assets of assetPagination) { - for (const asset of assets) { - const user = users.find((user) => user.id === asset.ownerId); - const storageLabel = user?.storageLabel || null; - const filename = asset.originalFileName || asset.id; - await this.moveAsset(asset, { storageLabel, filename }); - } + for await (const asset of assets) { + const user = users.find((user) => user.id === asset.ownerId); + const storageLabel = user?.storageLabel || null; + const filename = asset.originalFileName || asset.id; + await this.moveAsset(asset, { storageLabel, filename }); } this.logger.debug('Cleaning up empty directories...'); @@ -175,7 +171,13 @@ export class StorageTemplateService extends BaseService { return JobStatus.SUCCESS; } - async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) { + @OnEvent({ name: 'asset.delete' }) + async handleMoveHistoryCleanup({ assetId }: ArgOf<'asset.delete'>) { + this.logger.debug(`Cleaning up move history for asset ${assetId}`); + await this.moveRepository.cleanMoveHistorySingle(assetId); + } + + async moveAsset(asset: StorageAsset, metadata: MoveAssetMetadata) { if (asset.isExternal || StorageCore.isAndroidMotionPath(asset.originalPath)) { // External assets are not affected by storage template // TODO: shouldn't this only apply to external assets? @@ -183,11 +185,11 @@ export class StorageTemplateService extends BaseService { } return this.databaseRepository.withLock(DatabaseLock.StorageTemplateMigration, async () => { - const { id, sidecarPath, originalPath, exifInfo, checksum } = asset; + const { id, sidecarPath, originalPath, checksum, fileSizeInByte } = asset; const oldPath = originalPath; const newPath = await this.getTemplatePath(asset, metadata); - if (!exifInfo || !exifInfo.fileSizeInByte) { + if (!fileSizeInByte) { this.logger.error(`Asset ${id} missing exif info, skipping storage template migration`); return; } @@ -198,7 +200,7 @@ export class StorageTemplateService extends BaseService { pathType: AssetPathType.ORIGINAL, oldPath, newPath, - assetInfo: { sizeInBytes: exifInfo.fileSizeInByte, checksum }, + assetInfo: { sizeInBytes: fileSizeInByte, checksum }, }); if (sidecarPath) { await this.storageCore.moveFile({ @@ -214,15 +216,42 @@ export class StorageTemplateService extends BaseService { }); } - private async getTemplatePath(asset: AssetEntity, metadata: MoveAssetMetadata): Promise { + private async getTemplatePath(asset: StorageAsset, metadata: MoveAssetMetadata): Promise { const { storageLabel, filename } = metadata; try { const source = asset.originalPath; - const extension = path.extname(source).split('.').pop() as string; + let extension = path.extname(source).split('.').pop() as string; const sanitized = sanitize(path.basename(filename, `.${extension}`)); + extension = extension?.toLowerCase(); const rootPath = StorageCore.getLibraryFolder({ id: asset.ownerId, storageLabel }); + switch (extension) { + case 'jpeg': + case 'jpe': { + extension = 'jpg'; + break; + } + case 'tif': { + extension = 'tiff'; + break; + } + case '3gpp': { + extension = '3gp'; + break; + } + case 'mpeg': + case 'mpe': { + extension = 'mpg'; + break; + } + case 'm2ts': + case 'm2t': { + extension = 'mts'; + break; + } + } + let albumName = null; if (this.template.needsAlbum) { const albums = await this.albumRepository.getByAssetId(asset.ownerId, asset.id); @@ -304,12 +333,13 @@ export class StorageTemplateService extends BaseService { 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 album: (albumName && sanitize(albumName.replaceAll(/\.+/g, ''))) || '', }; const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; - const zone = asset.exifInfo?.timeZone || systemTimeZone; + const zone = asset.timeZone || systemTimeZone; const dt = DateTime.fromJSDate(asset.fileCreatedAt, { zone }); for (const token of Object.values(storageTokens).flat()) { diff --git a/server/src/services/storage.service.spec.ts b/server/src/services/storage.service.spec.ts index 3a5bf3bad9..2d28489fae 100644 --- a/server/src/services/storage.service.spec.ts +++ b/server/src/services/storage.service.spec.ts @@ -1,23 +1,15 @@ import { SystemMetadataKey } from 'src/enum'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { StorageService } from 'src/services/storage.service'; -import { IConfigRepository, ILoggingRepository } from 'src/types'; import { ImmichStartupError } from 'src/utils/misc'; import { mockEnvData } from 'test/repositories/config.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(StorageService.name, () => { let sut: StorageService; - - let configMock: Mocked; - let loggerMock: Mocked; - let storageMock: Mocked; - let systemMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, configMock, loggerMock, storageMock, systemMock } = newTestService(StorageService)); + ({ sut, mocks } = newTestService(StorageService)); }); it('should work', () => { @@ -26,11 +18,11 @@ describe(StorageService.name, () => { describe('onBootstrap', () => { it('should enable mount folder checking', async () => { - systemMock.get.mockResolvedValue(null); + mocks.systemMetadata.get.mockResolvedValue(null); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, { + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, { mountChecks: { backups: true, 'encoded-video': true, @@ -40,22 +32,22 @@ describe(StorageService.name, () => { upload: true, }, }); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/encoded-video'); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library'); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile'); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs'); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/upload'); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/backups'); - expect(storageMock.createFile).toHaveBeenCalledWith('upload/encoded-video/.immich', expect.any(Buffer)); - expect(storageMock.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer)); - expect(storageMock.createFile).toHaveBeenCalledWith('upload/profile/.immich', expect.any(Buffer)); - expect(storageMock.createFile).toHaveBeenCalledWith('upload/thumbs/.immich', expect.any(Buffer)); - expect(storageMock.createFile).toHaveBeenCalledWith('upload/upload/.immich', expect.any(Buffer)); - expect(storageMock.createFile).toHaveBeenCalledWith('upload/backups/.immich', expect.any(Buffer)); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/encoded-video'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/profile'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/upload'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/backups'); + expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/encoded-video/.immich', expect.any(Buffer)); + expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer)); + expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/profile/.immich', expect.any(Buffer)); + expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/thumbs/.immich', expect.any(Buffer)); + expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/upload/.immich', expect.any(Buffer)); + expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/backups/.immich', expect.any(Buffer)); }); it('should enable mount folder checking for a new folder type', async () => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ mountChecks: { backups: false, 'encoded-video': true, @@ -68,7 +60,7 @@ describe(StorageService.name, () => { await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, { + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, { mountChecks: { backups: true, 'encoded-video': true, @@ -78,64 +70,68 @@ describe(StorageService.name, () => { upload: true, }, }); - expect(storageMock.mkdirSync).toHaveBeenCalledTimes(2); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library'); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/backups'); - expect(storageMock.createFile).toHaveBeenCalledTimes(2); - expect(storageMock.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer)); - expect(storageMock.createFile).toHaveBeenCalledWith('upload/backups/.immich', expect.any(Buffer)); + expect(mocks.storage.mkdirSync).toHaveBeenCalledTimes(2); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/backups'); + expect(mocks.storage.createFile).toHaveBeenCalledTimes(2); + expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer)); + expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/backups/.immich', expect.any(Buffer)); }); it('should throw an error if .immich is missing', async () => { - systemMock.get.mockResolvedValue({ mountChecks: { upload: true } }); - storageMock.readFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); + mocks.systemMetadata.get.mockResolvedValue({ mountChecks: { upload: true } }); + mocks.storage.readFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); await expect(sut.onBootstrap()).rejects.toThrow('Failed to read'); - expect(storageMock.createOrOverwriteFile).not.toHaveBeenCalled(); - expect(systemMock.set).not.toHaveBeenCalled(); + expect(mocks.storage.createOrOverwriteFile).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); }); it('should throw an error if .immich is present but read-only', async () => { - systemMock.get.mockResolvedValue({ mountChecks: { upload: true } }); - storageMock.overwriteFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); + mocks.systemMetadata.get.mockResolvedValue({ mountChecks: { upload: true } }); + mocks.storage.overwriteFile.mockRejectedValue( + new Error("ENOENT: no such file or directory, open '/app/.immich'"), + ); await expect(sut.onBootstrap()).rejects.toThrow('Failed to write'); - expect(systemMock.set).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); }); it('should skip mount file creation if file already exists', async () => { const error = new Error('Error creating file') as any; error.code = 'EEXIST'; - systemMock.get.mockResolvedValue({ mountChecks: {} }); - storageMock.createFile.mockRejectedValue(error); + mocks.systemMetadata.get.mockResolvedValue({ mountChecks: {} }); + mocks.storage.createFile.mockRejectedValue(error); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(loggerMock.warn).toHaveBeenCalledWith('Found existing mount file, skipping creation'); + expect(mocks.logger.warn).toHaveBeenCalledWith('Found existing mount file, skipping creation'); }); it('should throw an error if mount file could not be created', async () => { - systemMock.get.mockResolvedValue({ mountChecks: {} }); - storageMock.createFile.mockRejectedValue(new Error('Error creating file')); + mocks.systemMetadata.get.mockResolvedValue({ mountChecks: {} }); + mocks.storage.createFile.mockRejectedValue(new Error('Error creating file')); await expect(sut.onBootstrap()).rejects.toBeInstanceOf(ImmichStartupError); - expect(systemMock.set).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); }); it('should startup if checks are disabled', async () => { - systemMock.get.mockResolvedValue({ mountChecks: { upload: true } }); - configMock.getEnv.mockReturnValue( + mocks.systemMetadata.get.mockResolvedValue({ mountChecks: { upload: true } }); + mocks.config.getEnv.mockReturnValue( mockEnvData({ storage: { ignoreMountCheckErrors: true }, }), ); - storageMock.overwriteFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); + mocks.storage.overwriteFile.mockRejectedValue( + new Error("ENOENT: no such file or directory, open '/app/.immich'"), + ); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(systemMock.set).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); }); }); @@ -143,21 +139,21 @@ describe(StorageService.name, () => { it('should handle null values', async () => { await sut.handleDeleteFiles({ files: [undefined, null] }); - expect(storageMock.unlink).not.toHaveBeenCalled(); + expect(mocks.storage.unlink).not.toHaveBeenCalled(); }); it('should handle an error removing a file', async () => { - storageMock.unlink.mockRejectedValue(new Error('something-went-wrong')); + mocks.storage.unlink.mockRejectedValue(new Error('something-went-wrong')); await sut.handleDeleteFiles({ files: ['path/to/something'] }); - expect(storageMock.unlink).toHaveBeenCalledWith('path/to/something'); + expect(mocks.storage.unlink).toHaveBeenCalledWith('path/to/something'); }); it('should remove the file', async () => { await sut.handleDeleteFiles({ files: ['path/to/something'] }); - expect(storageMock.unlink).toHaveBeenCalledWith('path/to/something'); + expect(mocks.storage.unlink).toHaveBeenCalledWith('path/to/something'); }); }); }); diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index ce26df4869..ca1d9e7921 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -3,10 +3,9 @@ import { join } from 'node:path'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent, OnJob } from 'src/decorators'; import { SystemFlags } from 'src/entities/system-metadata.entity'; -import { StorageFolder, SystemMetadataKey } from 'src/enum'; -import { DatabaseLock } from 'src/interfaces/database.interface'; -import { JobName, JobOf, JobStatus, QueueName } from 'src/interfaces/job.interface'; +import { DatabaseLock, JobName, JobStatus, QueueName, StorageFolder, SystemMetadataKey } from 'src/enum'; import { BaseService } from 'src/services/base.service'; +import { JobOf } from 'src/types'; import { ImmichStartupError } from 'src/utils/misc'; const docsMessage = `Please see https://immich.app/docs/administration/system-integrity#folder-checks for more information.`; @@ -55,7 +54,7 @@ export class StorageService extends BaseService { this.logger.log('Successfully verified system mount folder checks'); } catch (error) { if (envData.storage.ignoreMountCheckErrors) { - this.logger.error(error); + this.logger.error(error as Error); this.logger.warn('Ignoring mount folder errors'); } else { throw error; diff --git a/server/src/services/sync.service.spec.ts b/server/src/services/sync.service.spec.ts index 3bedd13d8f..d5e53c83a2 100644 --- a/server/src/services/sync.service.spec.ts +++ b/server/src/services/sync.service.spec.ts @@ -1,27 +1,20 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { AssetEntity } from 'src/entities/asset.entity'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { SyncService } from 'src/services/sync.service'; -import { IAuditRepository } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { partnerStub } from 'test/fixtures/partner.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; const untilDate = new Date(2024); const mapAssetOpts = { auth: authStub.user1, stripMetadata: false, withStack: true }; describe(SyncService.name, () => { let sut: SyncService; - - let assetMock: Mocked; - let auditMock: Mocked; - let partnerMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, assetMock, auditMock, partnerMock } = newTestService(SyncService)); + ({ sut, mocks } = newTestService(SyncService)); }); it('should exist', () => { @@ -30,12 +23,12 @@ describe(SyncService.name, () => { describe('getAllAssetsForUserFullSync', () => { it('should return a list of all assets owned by the user', async () => { - assetMock.getAllForUserFullSync.mockResolvedValue([assetStub.external, assetStub.hasEncodedVideo]); + mocks.asset.getAllForUserFullSync.mockResolvedValue([assetStub.external, assetStub.hasEncodedVideo]); await expect(sut.getFullSync(authStub.user1, { limit: 2, updatedUntil: untilDate })).resolves.toEqual([ mapAsset(assetStub.external, mapAssetOpts), mapAsset(assetStub.hasEncodedVideo, mapAssetOpts), ]); - expect(assetMock.getAllForUserFullSync).toHaveBeenCalledWith({ + expect(mocks.asset.getAllForUserFullSync).toHaveBeenCalledWith({ ownerId: authStub.user1.user.id, updatedUntil: untilDate, limit: 2, @@ -45,39 +38,39 @@ describe(SyncService.name, () => { describe('getChangesForDeltaSync', () => { it('should return a response requiring a full sync when partners are out of sync', async () => { - partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1]); + mocks.partner.getAll.mockResolvedValue([partnerStub.adminToUser1]); await expect( sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }), ).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] }); - expect(assetMock.getChangedDeltaSync).toHaveBeenCalledTimes(0); - expect(auditMock.getAfter).toHaveBeenCalledTimes(0); + expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(0); + expect(mocks.audit.getAfter).toHaveBeenCalledTimes(0); }); it('should return a response requiring a full sync when last sync was too long ago', async () => { - partnerMock.getAll.mockResolvedValue([]); + mocks.partner.getAll.mockResolvedValue([]); await expect( sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(2000), userIds: [authStub.user1.user.id] }), ).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] }); - expect(assetMock.getChangedDeltaSync).toHaveBeenCalledTimes(0); - expect(auditMock.getAfter).toHaveBeenCalledTimes(0); + expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(0); + expect(mocks.audit.getAfter).toHaveBeenCalledTimes(0); }); it('should return a response requiring a full sync when there are too many changes', async () => { - partnerMock.getAll.mockResolvedValue([]); - assetMock.getChangedDeltaSync.mockResolvedValue( + mocks.partner.getAll.mockResolvedValue([]); + mocks.asset.getChangedDeltaSync.mockResolvedValue( Array.from({ length: 10_000 }).fill(assetStub.image), ); await expect( sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }), ).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] }); - expect(assetMock.getChangedDeltaSync).toHaveBeenCalledTimes(1); - expect(auditMock.getAfter).toHaveBeenCalledTimes(0); + expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(1); + expect(mocks.audit.getAfter).toHaveBeenCalledTimes(0); }); it('should return a response with changes and deletions', async () => { - partnerMock.getAll.mockResolvedValue([]); - assetMock.getChangedDeltaSync.mockResolvedValue([assetStub.image1]); - auditMock.getAfter.mockResolvedValue([assetStub.external.id]); + mocks.partner.getAll.mockResolvedValue([]); + mocks.asset.getChangedDeltaSync.mockResolvedValue([assetStub.image1]); + mocks.audit.getAfter.mockResolvedValue([assetStub.external.id]); await expect( sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }), ).resolves.toEqual({ @@ -85,8 +78,8 @@ describe(SyncService.name, () => { upserted: [mapAsset(assetStub.image1, mapAssetOpts)], deleted: [assetStub.external.id], }); - expect(assetMock.getChangedDeltaSync).toHaveBeenCalledTimes(1); - expect(auditMock.getAfter).toHaveBeenCalledTimes(1); + expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(1); + expect(mocks.audit.getAfter).toHaveBeenCalledTimes(1); }); }); }); diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index f85200db48..c88348b39e 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -1,16 +1,220 @@ +import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; +import { Insertable } from 'kysely'; import { DateTime } from 'luxon'; +import { Writable } from 'node:stream'; import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; -import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; +import { SessionSyncCheckpoints } from 'src/db'; +import { AssetResponseDto, hexOrBufferToBase64, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetDeltaSyncDto, AssetDeltaSyncResponseDto, AssetFullSyncDto } from 'src/dtos/sync.dto'; -import { DatabaseAction, EntityType, Permission } from 'src/enum'; +import { + AssetDeltaSyncDto, + AssetDeltaSyncResponseDto, + AssetFullSyncDto, + SyncAckDeleteDto, + SyncAckSetDto, + SyncStreamDto, +} from 'src/dtos/sync.dto'; +import { DatabaseAction, EntityType, Permission, SyncEntityType, SyncRequestType } from 'src/enum'; import { BaseService } from 'src/services/base.service'; +import { SyncAck } from 'src/types'; import { getMyPartnerIds } from 'src/utils/asset.util'; import { setIsEqual } from 'src/utils/set'; +import { fromAck, serialize } from 'src/utils/sync'; const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] }; +export const SYNC_TYPES_ORDER = [ + // + SyncRequestType.UsersV1, + SyncRequestType.PartnersV1, + SyncRequestType.AssetsV1, + SyncRequestType.AssetExifsV1, + SyncRequestType.PartnerAssetsV1, + SyncRequestType.PartnerAssetExifsV1, +]; +const throwSessionRequired = () => { + throw new ForbiddenException('Sync endpoints cannot be used with API keys'); +}; + +@Injectable() export class SyncService extends BaseService { + getAcks(auth: AuthDto) { + const sessionId = auth.session?.id; + if (!sessionId) { + return throwSessionRequired(); + } + + return this.syncRepository.getCheckpoints(sessionId); + } + + async setAcks(auth: AuthDto, dto: SyncAckSetDto) { + const sessionId = auth.session?.id; + if (!sessionId) { + return throwSessionRequired(); + } + + const checkpoints: Record> = {}; + for (const ack of dto.acks) { + const { type } = fromAck(ack); + // TODO proper ack validation via class validator + if (!Object.values(SyncEntityType).includes(type)) { + throw new BadRequestException(`Invalid ack type: ${type}`); + } + + if (checkpoints[type]) { + throw new BadRequestException('Only one ack per type is allowed'); + } + + checkpoints[type] = { sessionId, type, ack }; + } + + await this.syncRepository.upsertCheckpoints(Object.values(checkpoints)); + } + + async deleteAcks(auth: AuthDto, dto: SyncAckDeleteDto) { + const sessionId = auth.session?.id; + if (!sessionId) { + return throwSessionRequired(); + } + + await this.syncRepository.deleteCheckpoints(sessionId, dto.types); + } + + async stream(auth: AuthDto, response: Writable, dto: SyncStreamDto) { + const sessionId = auth.session?.id; + if (!sessionId) { + return throwSessionRequired(); + } + + const checkpoints = await this.syncRepository.getCheckpoints(sessionId); + const checkpointMap: Partial> = Object.fromEntries( + checkpoints.map(({ type, ack }) => [type, fromAck(ack)]), + ); + + for (const type of SYNC_TYPES_ORDER.filter((type) => dto.types.includes(type))) { + switch (type) { + case SyncRequestType.UsersV1: { + const deletes = this.syncRepository.getUserDeletes(checkpointMap[SyncEntityType.UserDeleteV1]); + for await (const { id, ...data } of deletes) { + response.write(serialize({ type: SyncEntityType.UserDeleteV1, updateId: id, data })); + } + + const upserts = this.syncRepository.getUserUpserts(checkpointMap[SyncEntityType.UserV1]); + for await (const { updateId, ...data } of upserts) { + response.write(serialize({ type: SyncEntityType.UserV1, updateId, data })); + } + + break; + } + + case SyncRequestType.PartnersV1: { + const deletes = this.syncRepository.getPartnerDeletes( + auth.user.id, + checkpointMap[SyncEntityType.PartnerDeleteV1], + ); + for await (const { id, ...data } of deletes) { + response.write(serialize({ type: SyncEntityType.PartnerDeleteV1, updateId: id, data })); + } + + const upserts = this.syncRepository.getPartnerUpserts(auth.user.id, checkpointMap[SyncEntityType.PartnerV1]); + for await (const { updateId, ...data } of upserts) { + response.write(serialize({ type: SyncEntityType.PartnerV1, updateId, data })); + } + + break; + } + + case SyncRequestType.AssetsV1: { + const deletes = this.syncRepository.getAssetDeletes( + auth.user.id, + checkpointMap[SyncEntityType.AssetDeleteV1], + ); + for await (const { id, ...data } of deletes) { + response.write(serialize({ type: SyncEntityType.AssetDeleteV1, updateId: id, data })); + } + + const upserts = this.syncRepository.getAssetUpserts(auth.user.id, checkpointMap[SyncEntityType.AssetV1]); + for await (const { updateId, checksum, thumbhash, ...data } of upserts) { + response.write( + serialize({ + type: SyncEntityType.AssetV1, + updateId, + data: { + ...data, + checksum: hexOrBufferToBase64(checksum), + thumbhash: thumbhash ? hexOrBufferToBase64(thumbhash) : null, + }, + }), + ); + } + + break; + } + + case SyncRequestType.PartnerAssetsV1: { + const deletes = this.syncRepository.getPartnerAssetDeletes( + auth.user.id, + checkpointMap[SyncEntityType.PartnerAssetDeleteV1], + ); + for await (const { id, ...data } of deletes) { + response.write(serialize({ type: SyncEntityType.PartnerAssetDeleteV1, updateId: id, data })); + } + + const upserts = this.syncRepository.getPartnerAssetsUpserts( + auth.user.id, + checkpointMap[SyncEntityType.PartnerAssetV1], + ); + for await (const { updateId, checksum, thumbhash, ...data } of upserts) { + response.write( + serialize({ + type: SyncEntityType.PartnerAssetV1, + updateId, + data: { + ...data, + checksum: hexOrBufferToBase64(checksum), + thumbhash: thumbhash ? hexOrBufferToBase64(thumbhash) : null, + }, + }), + ); + } + + break; + } + + case SyncRequestType.AssetExifsV1: { + const upserts = this.syncRepository.getAssetExifsUpserts( + auth.user.id, + checkpointMap[SyncEntityType.AssetExifV1], + ); + for await (const { updateId, ...data } of upserts) { + response.write(serialize({ type: SyncEntityType.AssetExifV1, updateId, data })); + } + + break; + } + + case SyncRequestType.PartnerAssetExifsV1: { + const upserts = this.syncRepository.getPartnerAssetExifsUpserts( + auth.user.id, + checkpointMap[SyncEntityType.PartnerAssetExifV1], + ); + for await (const { updateId, ...data } of upserts) { + response.write(serialize({ type: SyncEntityType.PartnerAssetExifV1, updateId, data })); + } + + break; + } + + default: { + this.logger.warn(`Unsupported sync type: ${type}`); + break; + } + } + } + + response.end(); + } + async getFullSync(auth: AuthDto, dto: AssetFullSyncDto): Promise { // mobile implementation is faster if this is a single id const userId = dto.userId || auth.user.id; diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 02166cdeb8..8a06a883c2 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -6,20 +6,17 @@ import { CQMode, ImageFormat, LogLevel, + QueueName, ToneMapping, TranscodeHWAccel, TranscodePolicy, VideoCodec, VideoContainer, } from 'src/enum'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { QueueName } from 'src/interfaces/job.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { SystemConfigService } from 'src/services/system-config.service'; -import { DeepPartial, IConfigRepository, ILoggingRepository } from 'src/types'; +import { DeepPartial } from 'src/types'; import { mockEnvData } from 'test/repositories/config.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; const partialConfig = { ffmpeg: { crf: 30 }, @@ -199,14 +196,10 @@ const updatedConfig = Object.freeze({ describe(SystemConfigService.name, () => { let sut: SystemConfigService; - - let configMock: Mocked; - let eventMock: Mocked; - let loggerMock: Mocked; - let systemMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, configMock, eventMock, loggerMock, systemMock } = newTestService(SystemConfigService)); + ({ sut, mocks } = newTestService(SystemConfigService)); }); it('should work', () => { @@ -215,22 +208,22 @@ describe(SystemConfigService.name, () => { describe('getDefaults', () => { it('should return the default config', () => { - systemMock.get.mockResolvedValue(partialConfig); + mocks.systemMetadata.get.mockResolvedValue(partialConfig); expect(sut.getDefaults()).toEqual(defaults); - expect(systemMock.get).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.get).not.toHaveBeenCalled(); }); }); describe('getConfig', () => { it('should return the default config', async () => { - systemMock.get.mockResolvedValue({}); + mocks.systemMetadata.get.mockResolvedValue({}); await expect(sut.getSystemConfig()).resolves.toEqual(defaults); }); it('should merge the overrides', async () => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { crf: 30 }, oauth: { autoLaunch: true }, trash: { days: 10 }, @@ -241,17 +234,17 @@ describe(SystemConfigService.name, () => { }); it('should load the config from a json file', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); - systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig)); await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); - expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json'); + expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json'); }); it('should transform booleans', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); - systemMock.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { twoPass: 'false' } })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { twoPass: 'false' } })); await expect(sut.getSystemConfig()).resolves.toMatchObject({ ffmpeg: expect.objectContaining({ twoPass: false }), @@ -259,8 +252,8 @@ describe(SystemConfigService.name, () => { }); it('should transform numbers', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); - systemMock.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { threads: '42' } })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { threads: '42' } })); await expect(sut.getSystemConfig()).resolves.toMatchObject({ ffmpeg: expect.objectContaining({ threads: 42 }), @@ -268,8 +261,10 @@ describe(SystemConfigService.name, () => { }); it('should accept valid cron expressions', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); - systemMock.readFile.mockResolvedValue(JSON.stringify({ library: { scan: { cronExpression: '0 0 * * *' } } })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.systemMetadata.readFile.mockResolvedValue( + JSON.stringify({ library: { scan: { cronExpression: '0 0 * * *' } } }), + ); await expect(sut.getSystemConfig()).resolves.toMatchObject({ library: { @@ -282,8 +277,8 @@ describe(SystemConfigService.name, () => { }); it('should reject invalid cron expressions', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); - systemMock.readFile.mockResolvedValue(JSON.stringify({ library: { scan: { cronExpression: 'foo' } } })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ library: { scan: { cronExpression: 'foo' } } })); await expect(sut.getSystemConfig()).rejects.toThrow( 'library.scan.cronExpression has failed the following constraints: cronValidator', @@ -291,22 +286,22 @@ describe(SystemConfigService.name, () => { }); it('should log errors with the config file', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); - systemMock.readFile.mockResolvedValue(`{ "ffmpeg2": true, "ffmpeg2": true }`); + mocks.systemMetadata.readFile.mockResolvedValue(`{ "ffmpeg2": true, "ffmpeg2": true }`); await expect(sut.getSystemConfig()).rejects.toBeInstanceOf(Error); - expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json'); - expect(loggerMock.error).toHaveBeenCalledTimes(2); - expect(loggerMock.error.mock.calls[0][0]).toEqual('Unable to load configuration file: immich-config.json'); - expect(loggerMock.error.mock.calls[1][0].toString()).toEqual( + expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json'); + expect(mocks.logger.error).toHaveBeenCalledTimes(2); + expect(mocks.logger.error.mock.calls[0][0]).toEqual('Unable to load configuration file: immich-config.json'); + expect(mocks.logger.error.mock.calls[1][0].toString()).toEqual( expect.stringContaining('YAMLException: duplicated mapping key (1:20)'), ); }); it('should load the config from a yaml file', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.yaml' })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.yaml' })); const partialConfig = ` ffmpeg: crf: 30 @@ -317,26 +312,26 @@ describe(SystemConfigService.name, () => { user: deleteDelay: 15 `; - systemMock.readFile.mockResolvedValue(partialConfig); + mocks.systemMetadata.readFile.mockResolvedValue(partialConfig); await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); - expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.yaml'); + expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.yaml'); }); it('should accept an empty configuration file', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); - systemMock.readFile.mockResolvedValue(JSON.stringify({})); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({})); await expect(sut.getSystemConfig()).resolves.toEqual(defaults); - expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json'); + expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json'); }); it('should allow underscores in the machine learning url', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); const partialConfig = { machineLearning: { urls: ['immich_machine_learning'] } }; - systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); + mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig)); const config = await sut.getSystemConfig(); expect(config.machineLearning.urls).toEqual(['immich_machine_learning']); @@ -350,9 +345,9 @@ describe(SystemConfigService.name, () => { for (const { should, externalDomain, result } of externalDomainTests) { it(`should normalize an external domain ${should}`, async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); const partialConfig = { server: { externalDomain } }; - systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); + mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig)); const config = await sut.getSystemConfig(); expect(config.server.externalDomain).toEqual(result ?? 'https://demo.immich.app'); @@ -360,14 +355,14 @@ describe(SystemConfigService.name, () => { } it('should warn for unknown options in yaml', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.yaml' })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.yaml' })); const partialConfig = ` unknownOption: true `; - systemMock.readFile.mockResolvedValue(partialConfig); + mocks.systemMetadata.readFile.mockResolvedValue(partialConfig); await sut.getSystemConfig(); - expect(loggerMock.warn).toHaveBeenCalled(); + expect(mocks.logger.warn).toHaveBeenCalled(); }); const tests = [ @@ -381,12 +376,12 @@ describe(SystemConfigService.name, () => { for (const test of tests) { it(`should ${test.should}`, async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); - systemMock.readFile.mockResolvedValue(JSON.stringify(test.config)); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(test.config)); if (test.warn) { await sut.getSystemConfig(); - expect(loggerMock.warn).toHaveBeenCalled(); + expect(mocks.logger.warn).toHaveBeenCalled(); } else { await expect(sut.getSystemConfig()).rejects.toBeInstanceOf(Error); } @@ -396,19 +391,19 @@ describe(SystemConfigService.name, () => { describe('updateConfig', () => { it('should update the config and emit an event', async () => { - systemMock.get.mockResolvedValue(partialConfig); + mocks.systemMetadata.get.mockResolvedValue(partialConfig); await expect(sut.updateSystemConfig(updatedConfig)).resolves.toEqual(updatedConfig); - expect(eventMock.emit).toHaveBeenCalledWith( + expect(mocks.event.emit).toHaveBeenCalledWith( 'config.update', expect.objectContaining({ oldConfig: expect.any(Object), newConfig: updatedConfig }), ); }); it('should throw an error if a config file is in use', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); - systemMock.readFile.mockResolvedValue(JSON.stringify({})); + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({})); await expect(sut.updateSystemConfig(defaults)).rejects.toBeInstanceOf(BadRequestException); - expect(systemMock.set).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); }); }); diff --git a/server/src/services/system-config.service.ts b/server/src/services/system-config.service.ts index b5ae42e098..2dd8dcf0ee 100644 --- a/server/src/services/system-config.service.ts +++ b/server/src/services/system-config.service.ts @@ -3,8 +3,9 @@ import { instanceToPlain } from 'class-transformer'; import _ from 'lodash'; import { defaults } from 'src/config'; import { OnEvent } from 'src/decorators'; -import { SystemConfigDto, mapConfig } from 'src/dtos/system-config.dto'; -import { ArgOf, BootstrapEventPriority } from 'src/interfaces/event.interface'; +import { mapConfig, SystemConfigDto } from 'src/dtos/system-config.dto'; +import { BootstrapEventPriority } from 'src/enum'; +import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; import { clearConfigCache } from 'src/utils/config'; import { toPlainObject } from 'src/utils/object'; diff --git a/server/src/services/system-metadata.service.spec.ts b/server/src/services/system-metadata.service.spec.ts index 3dc2f0a6bb..a8d6c0cdcc 100644 --- a/server/src/services/system-metadata.service.spec.ts +++ b/server/src/services/system-metadata.service.spec.ts @@ -1,15 +1,13 @@ import { SystemMetadataKey } from 'src/enum'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { SystemMetadataService } from 'src/services/system-metadata.service'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(SystemMetadataService.name, () => { let sut: SystemMetadataService; - let systemMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, systemMock } = newTestService(SystemMetadataService)); + ({ sut, mocks } = newTestService(SystemMetadataService)); }); it('should work', () => { @@ -18,32 +16,32 @@ describe(SystemMetadataService.name, () => { describe('getAdminOnboarding', () => { it('should get isOnboarded state', async () => { - systemMock.get.mockResolvedValue({ isOnboarded: true }); + mocks.systemMetadata.get.mockResolvedValue({ isOnboarded: true }); await expect(sut.getAdminOnboarding()).resolves.toEqual({ isOnboarded: true }); - expect(systemMock.get).toHaveBeenCalledWith('admin-onboarding'); + expect(mocks.systemMetadata.get).toHaveBeenCalledWith('admin-onboarding'); }); it('should default isOnboarded to false', async () => { await expect(sut.getAdminOnboarding()).resolves.toEqual({ isOnboarded: false }); - expect(systemMock.get).toHaveBeenCalledWith('admin-onboarding'); + expect(mocks.systemMetadata.get).toHaveBeenCalledWith('admin-onboarding'); }); }); describe('updateAdminOnboarding', () => { it('should update isOnboarded to true', async () => { await expect(sut.updateAdminOnboarding({ isOnboarded: true })).resolves.toBeUndefined(); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true }); + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true }); }); it('should update isOnboarded to false', async () => { await expect(sut.updateAdminOnboarding({ isOnboarded: false })).resolves.toBeUndefined(); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: false }); + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: false }); }); }); describe('getReverseGeocodingState', () => { it('should get reverse geocoding state', async () => { - systemMock.get.mockResolvedValue({ lastUpdate: '2024-01-01', lastImportFileName: 'foo.bar' }); + mocks.systemMetadata.get.mockResolvedValue({ lastUpdate: '2024-01-01', lastImportFileName: 'foo.bar' }); await expect(sut.getReverseGeocodingState()).resolves.toEqual({ lastUpdate: '2024-01-01', lastImportFileName: 'foo.bar', diff --git a/server/src/services/tag.service.spec.ts b/server/src/services/tag.service.spec.ts index 8d54eb31db..70507ab433 100644 --- a/server/src/services/tag.service.spec.ts +++ b/server/src/services/tag.service.spec.ts @@ -1,24 +1,19 @@ import { BadRequestException } from '@nestjs/common'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; -import { JobStatus } from 'src/interfaces/job.interface'; -import { ITagRepository } from 'src/interfaces/tag.interface'; +import { JobStatus } from 'src/enum'; import { TagService } from 'src/services/tag.service'; import { authStub } from 'test/fixtures/auth.stub'; import { tagResponseStub, tagStub } from 'test/fixtures/tag.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(TagService.name, () => { let sut: TagService; - - let accessMock: IAccessRepositoryMock; - let tagMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, tagMock } = newTestService(TagService)); + ({ sut, mocks } = newTestService(TagService)); - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1'])); + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1'])); }); it('should work', () => { @@ -27,76 +22,78 @@ describe(TagService.name, () => { describe('getAll', () => { it('should return all tags for a user', async () => { - tagMock.getAll.mockResolvedValue([tagStub.tag1]); + mocks.tag.getAll.mockResolvedValue([tagStub.tag]); await expect(sut.getAll(authStub.admin)).resolves.toEqual([tagResponseStub.tag1]); - expect(tagMock.getAll).toHaveBeenCalledWith(authStub.admin.user.id); + expect(mocks.tag.getAll).toHaveBeenCalledWith(authStub.admin.user.id); }); }); describe('get', () => { it('should throw an error for an invalid id', async () => { - tagMock.get.mockResolvedValue(null); await expect(sut.get(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException); - expect(tagMock.get).toHaveBeenCalledWith('tag-1'); + expect(mocks.tag.get).toHaveBeenCalledWith('tag-1'); }); it('should return a tag for a user', async () => { - tagMock.get.mockResolvedValue(tagStub.tag1); + mocks.tag.get.mockResolvedValue(tagStub.tag); await expect(sut.get(authStub.admin, 'tag-1')).resolves.toEqual(tagResponseStub.tag1); - expect(tagMock.get).toHaveBeenCalledWith('tag-1'); + expect(mocks.tag.get).toHaveBeenCalledWith('tag-1'); }); }); describe('create', () => { it('should throw an error for no parent tag access', async () => { - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set()); await expect(sut.create(authStub.admin, { name: 'tag', parentId: 'tag-parent' })).rejects.toBeInstanceOf( BadRequestException, ); - expect(tagMock.create).not.toHaveBeenCalled(); + expect(mocks.tag.create).not.toHaveBeenCalled(); }); it('should create a tag with a parent', async () => { - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent'])); - tagMock.create.mockResolvedValue(tagStub.tag1); - tagMock.get.mockResolvedValueOnce(tagStub.parent); - tagMock.get.mockResolvedValueOnce(tagStub.child); + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent'])); + mocks.tag.create.mockResolvedValue(tagStub.tagCreate); + mocks.tag.get.mockResolvedValueOnce(tagStub.parentUpsert); + mocks.tag.get.mockResolvedValueOnce(tagStub.childUpsert); await expect(sut.create(authStub.admin, { name: 'tagA', parentId: 'tag-parent' })).resolves.toBeDefined(); - expect(tagMock.create).toHaveBeenCalledWith(expect.objectContaining({ value: 'Parent/tagA' })); + expect(mocks.tag.create).toHaveBeenCalledWith(expect.objectContaining({ value: 'Parent/tagA' })); }); it('should handle invalid parent ids', async () => { - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent'])); + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent'])); await expect(sut.create(authStub.admin, { name: 'tagA', parentId: 'tag-parent' })).rejects.toBeInstanceOf( BadRequestException, ); - expect(tagMock.create).not.toHaveBeenCalled(); + expect(mocks.tag.create).not.toHaveBeenCalled(); }); }); describe('create', () => { it('should throw an error for a duplicate tag', async () => { - tagMock.getByValue.mockResolvedValue(tagStub.tag1); + mocks.tag.getByValue.mockResolvedValue(tagStub.tag); await expect(sut.create(authStub.admin, { name: 'tag-1' })).rejects.toBeInstanceOf(BadRequestException); - expect(tagMock.getByValue).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.create).not.toHaveBeenCalled(); + expect(mocks.tag.getByValue).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); + expect(mocks.tag.create).not.toHaveBeenCalled(); }); it('should create a new tag', async () => { - tagMock.create.mockResolvedValue(tagStub.tag1); + mocks.tag.create.mockResolvedValue(tagStub.tagCreate); await expect(sut.create(authStub.admin, { name: 'tag-1' })).resolves.toEqual(tagResponseStub.tag1); - expect(tagMock.create).toHaveBeenCalledWith({ + expect(mocks.tag.create).toHaveBeenCalledWith({ userId: authStub.admin.user.id, value: 'tag-1', }); }); it('should create a new tag with optional color', async () => { - tagMock.create.mockResolvedValue(tagStub.color1); + mocks.tag.create.mockResolvedValue(tagStub.colorCreate); + mocks.tag.getByValue.mockResolvedValue(void 0); + await expect(sut.create(authStub.admin, { name: 'tag-1', color: '#000000' })).resolves.toEqual( tagResponseStub.color1, ); - expect(tagMock.create).toHaveBeenCalledWith({ + + expect(mocks.tag.create).toHaveBeenCalledWith({ userId: authStub.admin.user.id, value: 'tag-1', color: '#000000', @@ -106,26 +103,26 @@ describe(TagService.name, () => { describe('update', () => { it('should throw an error for no update permission', async () => { - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set()); await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).rejects.toBeInstanceOf( BadRequestException, ); - expect(tagMock.update).not.toHaveBeenCalled(); + expect(mocks.tag.update).not.toHaveBeenCalled(); }); it('should update a tag', async () => { - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1'])); - tagMock.update.mockResolvedValue(tagStub.color1); + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1'])); + mocks.tag.update.mockResolvedValue(tagStub.colorCreate); await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).resolves.toEqual(tagResponseStub.color1); - expect(tagMock.update).toHaveBeenCalledWith({ id: 'tag-1', color: '#000000' }); + expect(mocks.tag.update).toHaveBeenCalledWith('tag-1', { color: '#000000' }); }); }); describe('upsert', () => { it('should upsert a new tag', async () => { - tagMock.upsertValue.mockResolvedValue(tagStub.parent); + mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); await expect(sut.upsert(authStub.admin, { tags: ['Parent'] })).resolves.toBeDefined(); - expect(tagMock.upsertValue).toHaveBeenCalledWith({ + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ value: 'Parent', userId: 'admin_id', parentId: undefined, @@ -133,106 +130,106 @@ describe(TagService.name, () => { }); it('should upsert a nested tag', async () => { - tagMock.getByValue.mockResolvedValueOnce(null); - tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent); - tagMock.upsertValue.mockResolvedValueOnce(tagStub.child); + mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); + mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert); await expect(sut.upsert(authStub.admin, { tags: ['Parent/Child'] })).resolves.toBeDefined(); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { value: 'Parent', userId: 'admin_id', - parent: undefined, + parentId: undefined, }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { value: 'Parent/Child', userId: 'admin_id', - parent: expect.objectContaining({ id: 'tag-parent' }), + parentId: 'tag-parent', }); }); it('should upsert a tag and ignore leading and trailing slashes', async () => { - tagMock.getByValue.mockResolvedValueOnce(null); - tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent); - tagMock.upsertValue.mockResolvedValueOnce(tagStub.child); + mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); + mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert); await expect(sut.upsert(authStub.admin, { tags: ['/Parent/Child/'] })).resolves.toBeDefined(); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { value: 'Parent', userId: 'admin_id', - parent: undefined, + parentId: undefined, }); - expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { value: 'Parent/Child', userId: 'admin_id', - parent: expect.objectContaining({ id: 'tag-parent' }), + parentId: 'tag-parent', }); }); }); describe('remove', () => { it('should throw an error for an invalid id', async () => { - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set()); await expect(sut.remove(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException); - expect(tagMock.delete).not.toHaveBeenCalled(); + expect(mocks.tag.delete).not.toHaveBeenCalled(); }); it('should remove a tag', async () => { - tagMock.get.mockResolvedValue(tagStub.tag1); + mocks.tag.get.mockResolvedValue(tagStub.tag); + mocks.tag.delete.mockResolvedValue(); + await sut.remove(authStub.admin, 'tag-1'); - expect(tagMock.delete).toHaveBeenCalledWith('tag-1'); + expect(mocks.tag.delete).toHaveBeenCalledWith('tag-1'); }); }); describe('bulkTagAssets', () => { it('should handle invalid requests', async () => { - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set()); - tagMock.upsertAssetIds.mockResolvedValue([]); + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set()); + mocks.tag.upsertAssetIds.mockResolvedValue([]); await expect(sut.bulkTagAssets(authStub.admin, { tagIds: ['tag-1'], assetIds: ['asset-1'] })).resolves.toEqual({ count: 0, }); - expect(tagMock.upsertAssetIds).toHaveBeenCalledWith([]); + expect(mocks.tag.upsertAssetIds).toHaveBeenCalledWith([]); }); it('should upsert records', async () => { - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); - tagMock.upsertAssetIds.mockResolvedValue([ - { tagId: 'tag-1', assetId: 'asset-1' }, - { tagId: 'tag-1', assetId: 'asset-2' }, - { tagId: 'tag-1', assetId: 'asset-3' }, - { tagId: 'tag-2', assetId: 'asset-1' }, - { tagId: 'tag-2', assetId: 'asset-2' }, - { tagId: 'tag-2', assetId: 'asset-3' }, + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + mocks.tag.upsertAssetIds.mockResolvedValue([ + { tagsId: 'tag-1', assetsId: 'asset-1' }, + { tagsId: 'tag-1', assetsId: 'asset-2' }, + { tagsId: 'tag-1', assetsId: 'asset-3' }, + { tagsId: 'tag-2', assetsId: 'asset-1' }, + { tagsId: 'tag-2', assetsId: 'asset-2' }, + { tagsId: 'tag-2', assetsId: 'asset-3' }, ]); await expect( sut.bulkTagAssets(authStub.admin, { tagIds: ['tag-1', 'tag-2'], assetIds: ['asset-1', 'asset-2', 'asset-3'] }), ).resolves.toEqual({ count: 6, }); - expect(tagMock.upsertAssetIds).toHaveBeenCalledWith([ - { tagId: 'tag-1', assetId: 'asset-1' }, - { tagId: 'tag-1', assetId: 'asset-2' }, - { tagId: 'tag-1', assetId: 'asset-3' }, - { tagId: 'tag-2', assetId: 'asset-1' }, - { tagId: 'tag-2', assetId: 'asset-2' }, - { tagId: 'tag-2', assetId: 'asset-3' }, + expect(mocks.tag.upsertAssetIds).toHaveBeenCalledWith([ + { tagsId: 'tag-1', assetsId: 'asset-1' }, + { tagsId: 'tag-1', assetsId: 'asset-2' }, + { tagsId: 'tag-1', assetsId: 'asset-3' }, + { tagsId: 'tag-2', assetsId: 'asset-1' }, + { tagsId: 'tag-2', assetsId: 'asset-2' }, + { tagsId: 'tag-2', assetsId: 'asset-3' }, ]); }); }); describe('addAssets', () => { it('should handle invalid ids', async () => { - tagMock.get.mockResolvedValue(null); - tagMock.getAssetIds.mockResolvedValue(new Set([])); + mocks.tag.getAssetIds.mockResolvedValue(new Set([])); await expect(sut.addAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([ { id: 'asset-1', success: false, error: 'no_permission' }, ]); - expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']); - expect(tagMock.addAssetIds).not.toHaveBeenCalled(); + expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']); + expect(mocks.tag.addAssetIds).not.toHaveBeenCalled(); }); it('should accept accept ids that are new and reject the rest', async () => { - tagMock.get.mockResolvedValue(tagStub.tag1); - tagMock.getAssetIds.mockResolvedValue(new Set(['asset-1'])); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2'])); + mocks.tag.get.mockResolvedValue(tagStub.tag); + mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1'])); + mocks.tag.addAssetIds.mockResolvedValue(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2'])); await expect( sut.addAssets(authStub.admin, 'tag-1', { @@ -243,23 +240,25 @@ describe(TagService.name, () => { { id: 'asset-2', success: true }, ]); - expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']); - expect(tagMock.addAssetIds).toHaveBeenCalledWith('tag-1', ['asset-2']); + expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']); + expect(mocks.tag.addAssetIds).toHaveBeenCalledWith('tag-1', ['asset-2']); }); }); describe('removeAssets', () => { it('should throw an error for an invalid id', async () => { - tagMock.get.mockResolvedValue(null); - tagMock.getAssetIds.mockResolvedValue(new Set()); + mocks.tag.getAssetIds.mockResolvedValue(new Set()); + mocks.tag.removeAssetIds.mockResolvedValue(); + await expect(sut.removeAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([ { id: 'asset-1', success: false, error: 'not_found' }, ]); }); it('should accept accept ids that are tagged and reject the rest', async () => { - tagMock.get.mockResolvedValue(tagStub.tag1); - tagMock.getAssetIds.mockResolvedValue(new Set(['asset-1'])); + mocks.tag.get.mockResolvedValue(tagStub.tag); + mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1'])); + mocks.tag.removeAssetIds.mockResolvedValue(); await expect( sut.removeAssets(authStub.admin, 'tag-1', { @@ -270,15 +269,18 @@ describe(TagService.name, () => { { id: 'asset-2', success: false, error: BulkIdErrorReason.NOT_FOUND }, ]); - expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']); - expect(tagMock.removeAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']); + expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']); + expect(mocks.tag.removeAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']); }); }); describe('handleTagCleanup', () => { it('should delete empty tags', async () => { + mocks.tag.deleteEmptyTags.mockResolvedValue(); + await expect(sut.handleTagCleanup()).resolves.toBe(JobStatus.SUCCESS); - expect(tagMock.deleteEmptyTags).toHaveBeenCalled(); + + expect(mocks.tag.deleteEmptyTags).toHaveBeenCalled(); }); }); }); diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts index 4c31790d72..ecf4d6e9fb 100644 --- a/server/src/services/tag.service.ts +++ b/server/src/services/tag.service.ts @@ -1,4 +1,6 @@ import { BadRequestException, Injectable } from '@nestjs/common'; +import { Insertable } from 'kysely'; +import { TagAsset } from 'src/db'; import { OnJob } from 'src/decorators'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -11,10 +13,7 @@ import { TagUpsertDto, mapTag, } from 'src/dtos/tag.dto'; -import { TagEntity } from 'src/entities/tag.entity'; -import { Permission } from 'src/enum'; -import { JobName, JobStatus, QueueName } from 'src/interfaces/job.interface'; -import { AssetTagItem } from 'src/interfaces/tag.interface'; +import { JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { BaseService } from 'src/services/base.service'; import { addAssets, removeAssets } from 'src/utils/asset.util'; import { upsertTags } from 'src/utils/tag'; @@ -33,10 +32,10 @@ export class TagService extends BaseService { } async create(auth: AuthDto, dto: TagCreateDto) { - let parent: TagEntity | undefined; + let parent; if (dto.parentId) { await this.requireAccess({ auth, permission: Permission.TAG_READ, ids: [dto.parentId] }); - parent = (await this.tagRepository.get(dto.parentId)) || undefined; + parent = await this.tagRepository.get(dto.parentId); if (!parent) { throw new BadRequestException('Tag not found'); } @@ -50,7 +49,7 @@ export class TagService extends BaseService { } const { color } = dto; - const tag = await this.tagRepository.create({ userId, value, color, parent }); + const tag = await this.tagRepository.create({ userId, value, color, parentId: parent?.id }); return mapTag(tag); } @@ -59,7 +58,7 @@ export class TagService extends BaseService { await this.requireAccess({ auth, permission: Permission.TAG_UPDATE, ids: [id] }); const { color } = dto; - const tag = await this.tagRepository.update({ id, color }); + const tag = await this.tagRepository.update(id, { color }); return mapTag(tag); } @@ -82,15 +81,15 @@ export class TagService extends BaseService { this.checkAccess({ auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }), ]); - const items: AssetTagItem[] = []; - for (const tagId of tagIds) { - for (const assetId of assetIds) { - items.push({ tagId, assetId }); + const items: Insertable[] = []; + for (const tagsId of tagIds) { + for (const assetsId of assetIds) { + items.push({ tagsId, assetsId }); } } const results = await this.tagRepository.upsertAssetIds(items); - for (const assetId of new Set(results.map((item) => item.assetId))) { + for (const assetId of new Set(results.map((item) => item.assetsId))) { await this.eventRepository.emit('asset.tag', { assetId }); } diff --git a/server/src/services/timeline.service.spec.ts b/server/src/services/timeline.service.spec.ts index 41f9919189..1c2c422433 100644 --- a/server/src/services/timeline.service.spec.ts +++ b/server/src/services/timeline.service.spec.ts @@ -1,32 +1,28 @@ import { BadRequestException } from '@nestjs/common'; -import { IAssetRepository, TimeBucketSize } from 'src/interfaces/asset.interface'; +import { TimeBucketSize } from 'src/repositories/asset.repository'; import { TimelineService } from 'src/services/timeline.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(TimelineService.name, () => { let sut: TimelineService; - - let accessMock: IAccessRepositoryMock; - let assetMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, accessMock, assetMock } = newTestService(TimelineService)); + ({ sut, mocks } = newTestService(TimelineService)); }); describe('getTimeBuckets', () => { it("should return buckets if userId and albumId aren't set", async () => { - assetMock.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]); + mocks.asset.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]); await expect( sut.getTimeBuckets(authStub.admin, { size: TimeBucketSize.DAY, }), ).resolves.toEqual(expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }])); - expect(assetMock.getTimeBuckets).toHaveBeenCalledWith({ + expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith({ size: TimeBucketSize.DAY, userIds: [authStub.admin.user.id], }); @@ -35,15 +31,15 @@ describe(TimelineService.name, () => { describe('getTimeBucket', () => { it('should return the assets for a album time bucket if user has album.read', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); - assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); + mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); await expect( sut.getTimeBucket(authStub.admin, { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id' }), ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-id'])); - expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { + expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-id'])); + expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id', @@ -51,7 +47,7 @@ describe(TimelineService.name, () => { }); it('should return the assets for a archive time bucket if user has archive.read', async () => { - assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); await expect( sut.getTimeBucket(authStub.admin, { @@ -61,7 +57,7 @@ describe(TimelineService.name, () => { userId: authStub.admin.user.id, }), ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - expect(assetMock.getTimeBucket).toHaveBeenCalledWith( + expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith( 'bucket', expect.objectContaining({ size: TimeBucketSize.DAY, @@ -73,7 +69,8 @@ describe(TimelineService.name, () => { }); it('should include partner shared assets', async () => { - assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); + mocks.partner.getAll.mockResolvedValue([]); await expect( sut.getTimeBucket(authStub.admin, { @@ -84,7 +81,7 @@ describe(TimelineService.name, () => { withPartners: true, }), ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { + expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { size: TimeBucketSize.DAY, timeBucket: 'bucket', isArchived: false, @@ -94,8 +91,8 @@ describe(TimelineService.name, () => { }); it('should check permissions to read tag', async () => { - assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); - accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-123'])); + mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-123'])); await expect( sut.getTimeBucket(authStub.admin, { @@ -105,7 +102,7 @@ describe(TimelineService.name, () => { tagId: 'tag-123', }), ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { + expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { size: TimeBucketSize.DAY, tagId: 'tag-123', timeBucket: 'bucket', @@ -114,8 +111,8 @@ describe(TimelineService.name, () => { }); it('should strip metadata if showExif is disabled', async () => { - accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-id'])); - assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-id'])); + mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); const buckets = await sut.getTimeBucket( { ...authStub.admin, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } }, @@ -128,7 +125,7 @@ describe(TimelineService.name, () => { ); expect(buckets).toEqual([expect.objectContaining({ id: 'asset-id' })]); expect(buckets[0]).not.toHaveProperty('exif'); - expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { + expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { size: TimeBucketSize.DAY, timeBucket: 'bucket', isArchived: true, @@ -137,7 +134,7 @@ describe(TimelineService.name, () => { }); it('should return the assets for a library time bucket if user has library.read', async () => { - assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); await expect( sut.getTimeBucket(authStub.admin, { @@ -146,7 +143,7 @@ describe(TimelineService.name, () => { userId: authStub.admin.user.id, }), ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - expect(assetMock.getTimeBucket).toHaveBeenCalledWith( + expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith( 'bucket', expect.objectContaining({ size: TimeBucketSize.DAY, diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts index 04fd206fe7..4c2332afaa 100644 --- a/server/src/services/timeline.service.ts +++ b/server/src/services/timeline.service.ts @@ -1,12 +1,13 @@ -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; import { Permission } from 'src/enum'; -import { TimeBucketOptions } from 'src/interfaces/asset.interface'; +import { TimeBucketOptions } from 'src/repositories/asset.repository'; import { BaseService } from 'src/services/base.service'; import { getMyPartnerIds } from 'src/utils/asset.util'; +@Injectable() export class TimelineService extends BaseService { async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise { await this.timeBucketChecks(auth, dto); diff --git a/server/src/services/trash.service.spec.ts b/server/src/services/trash.service.spec.ts index 8b93e899e7..b3bee90815 100644 --- a/server/src/services/trash.service.spec.ts +++ b/server/src/services/trash.service.spec.ts @@ -1,11 +1,8 @@ import { BadRequestException } from '@nestjs/common'; -import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; +import { JobName, JobStatus } from 'src/enum'; import { TrashService } from 'src/services/trash.service'; -import { ITrashRepository } from 'src/types'; import { authStub } from 'test/fixtures/auth.stub'; -import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; async function* makeAssetIdStream(count: number): AsyncIterableIterator<{ id: string }> { for (let i = 0; i < count; i++) { @@ -16,17 +13,14 @@ async function* makeAssetIdStream(count: number): AsyncIterableIterator<{ id: st describe(TrashService.name, () => { let sut: TrashService; - - let accessMock: IAccessRepositoryMock; - let jobMock: Mocked; - let trashMock: Mocked; + let mocks: ServiceMocks; it('should work', () => { expect(sut).toBeDefined(); }); beforeEach(() => { - ({ sut, accessMock, jobMock, trashMock } = newTestService(TrashService)); + ({ sut, mocks } = newTestService(TrashService)); }); describe('restoreAssets', () => { @@ -40,64 +34,65 @@ describe(TrashService.name, () => { it('should handle an empty list', async () => { await expect(sut.restoreAssets(authStub.user1, { ids: [] })).resolves.toEqual({ count: 0 }); - expect(accessMock.asset.checkOwnerAccess).not.toHaveBeenCalled(); + expect(mocks.access.asset.checkOwnerAccess).not.toHaveBeenCalled(); }); it('should restore a batch of assets', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2'])); + mocks.trash.restoreAll.mockResolvedValue(0); await sut.restoreAssets(authStub.user1, { ids: ['asset1', 'asset2'] }); - expect(trashMock.restoreAll).toHaveBeenCalledWith(['asset1', 'asset2']); - expect(jobMock.queue.mock.calls).toEqual([]); + expect(mocks.trash.restoreAll).toHaveBeenCalledWith(['asset1', 'asset2']); + expect(mocks.job.queue.mock.calls).toEqual([]); }); }); describe('restore', () => { it('should handle an empty trash', async () => { - trashMock.getDeletedIds.mockResolvedValue(makeAssetIdStream(0)); - trashMock.restore.mockResolvedValue(0); + mocks.trash.getDeletedIds.mockResolvedValue(makeAssetIdStream(0)); + mocks.trash.restore.mockResolvedValue(0); await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 0 }); - expect(trashMock.restore).toHaveBeenCalledWith('user-id'); + expect(mocks.trash.restore).toHaveBeenCalledWith('user-id'); }); it('should restore', async () => { - trashMock.getDeletedIds.mockResolvedValue(makeAssetIdStream(1)); - trashMock.restore.mockResolvedValue(1); + mocks.trash.getDeletedIds.mockResolvedValue(makeAssetIdStream(1)); + mocks.trash.restore.mockResolvedValue(1); await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 1 }); - expect(trashMock.restore).toHaveBeenCalledWith('user-id'); + expect(mocks.trash.restore).toHaveBeenCalledWith('user-id'); }); }); describe('empty', () => { it('should handle an empty trash', async () => { - trashMock.getDeletedIds.mockResolvedValue(makeAssetIdStream(0)); - trashMock.empty.mockResolvedValue(0); + mocks.trash.getDeletedIds.mockResolvedValue(makeAssetIdStream(0)); + mocks.trash.empty.mockResolvedValue(0); await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 0 }); - expect(jobMock.queue).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); }); it('should empty the trash', async () => { - trashMock.getDeletedIds.mockResolvedValue(makeAssetIdStream(1)); - trashMock.empty.mockResolvedValue(1); + mocks.trash.getDeletedIds.mockResolvedValue(makeAssetIdStream(1)); + mocks.trash.empty.mockResolvedValue(1); await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 1 }); - expect(trashMock.empty).toHaveBeenCalledWith('user-id'); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); + expect(mocks.trash.empty).toHaveBeenCalledWith('user-id'); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); }); }); describe('onAssetsDelete', () => { it('should queue the empty trash job', async () => { await expect(sut.onAssetsDelete()).resolves.toBeUndefined(); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); }); }); describe('handleQueueEmptyTrash', () => { it('should queue asset delete jobs', async () => { - trashMock.getDeletedIds.mockReturnValue(makeAssetIdStream(1)); + mocks.trash.getDeletedIds.mockReturnValue(makeAssetIdStream(1)); await expect(sut.handleQueueEmptyTrash()).resolves.toEqual(JobStatus.SUCCESS); - expect(jobMock.queueAll).toHaveBeenCalledWith([ + expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.ASSET_DELETION, data: { id: 'asset-1', deleteOnDisk: true }, diff --git a/server/src/services/trash.service.ts b/server/src/services/trash.service.ts index d66461ef94..f33b249823 100644 --- a/server/src/services/trash.service.ts +++ b/server/src/services/trash.service.ts @@ -1,11 +1,13 @@ +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 { Permission } from 'src/enum'; -import { JOBS_ASSET_PAGINATION_SIZE, JobName, JobStatus, QueueName } from 'src/interfaces/job.interface'; +import { JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { BaseService } from 'src/services/base.service'; +@Injectable() export class TrashService extends BaseService { async restoreAssets(auth: AuthDto, dto: BulkIdsDto): Promise { const { ids } = dto; diff --git a/server/src/services/user-admin.service.spec.ts b/server/src/services/user-admin.service.spec.ts index 6d2bc31cb7..3e613bc485 100644 --- a/server/src/services/user-admin.service.spec.ts +++ b/server/src/services/user-admin.service.spec.ts @@ -1,31 +1,27 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { mapUserAdmin } from 'src/dtos/user.dto'; -import { UserStatus } from 'src/enum'; -import { IJobRepository, JobName } from 'src/interfaces/job.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { JobName, UserStatus } from 'src/enum'; import { UserAdminService } from 'src/services/user-admin.service'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { newTestService } from 'test/utils'; -import { Mocked, describe } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; +import { describe } from 'vitest'; describe(UserAdminService.name, () => { let sut: UserAdminService; - - let jobMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, jobMock, userMock } = newTestService(UserAdminService)); + ({ sut, mocks } = newTestService(UserAdminService)); - userMock.get.mockImplementation((userId) => + mocks.user.get.mockImplementation((userId) => Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? undefined), ); }); describe('create', () => { it('should not create a user if there is no local admin account', async () => { - userMock.getAdmin.mockResolvedValueOnce(void 0); + mocks.user.getAdmin.mockResolvedValueOnce(void 0); await expect( sut.create({ @@ -37,8 +33,8 @@ describe(UserAdminService.name, () => { }); it('should create user', async () => { - userMock.getAdmin.mockResolvedValue(userStub.admin); - userMock.create.mockResolvedValue(userStub.user1); + mocks.user.getAdmin.mockResolvedValue(userStub.admin); + mocks.user.create.mockResolvedValue(userStub.user1); await expect( sut.create({ @@ -49,8 +45,8 @@ describe(UserAdminService.name, () => { }), ).resolves.toEqual(mapUserAdmin(userStub.user1)); - expect(userMock.getAdmin).toBeCalled(); - expect(userMock.create).toBeCalledWith({ + expect(mocks.user.getAdmin).toBeCalled(); + expect(mocks.user.create).toBeCalledWith({ email: userStub.user1.email, name: userStub.user1.name, storageLabel: 'label', @@ -66,20 +62,20 @@ describe(UserAdminService.name, () => { email: 'immich@test.com', storageLabel: 'storage_label', }; - userMock.getByEmail.mockResolvedValue(void 0); - userMock.getByStorageLabel.mockResolvedValue(void 0); - userMock.update.mockResolvedValue(userStub.user1); + mocks.user.getByEmail.mockResolvedValue(void 0); + mocks.user.getByStorageLabel.mockResolvedValue(void 0); + mocks.user.update.mockResolvedValue(userStub.user1); await sut.update(authStub.user1, userStub.user1.id, update); - expect(userMock.getByEmail).toHaveBeenCalledWith(update.email); - expect(userMock.getByStorageLabel).toHaveBeenCalledWith(update.storageLabel); + expect(mocks.user.getByEmail).toHaveBeenCalledWith(update.email); + expect(mocks.user.getByStorageLabel).toHaveBeenCalledWith(update.storageLabel); }); it('should not set an empty string for storage label', async () => { - userMock.update.mockResolvedValue(userStub.user1); + mocks.user.update.mockResolvedValue(userStub.user1); await sut.update(authStub.admin, userStub.user1.id, { storageLabel: '' }); - expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { + expect(mocks.user.update).toHaveBeenCalledWith(userStub.user1.id, { storageLabel: null, updatedAt: expect.any(Date), }); @@ -88,27 +84,27 @@ describe(UserAdminService.name, () => { it('should not change an email to one already in use', async () => { const dto = { id: userStub.user1.id, email: 'updated@test.com' }; - userMock.get.mockResolvedValue(userStub.user1); - userMock.getByEmail.mockResolvedValue(userStub.admin); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.user.getByEmail.mockResolvedValue(userStub.admin); await expect(sut.update(authStub.admin, userStub.user1.id, dto)).rejects.toBeInstanceOf(BadRequestException); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('should not let the admin change the storage label to one already in use', async () => { const dto = { id: userStub.user1.id, storageLabel: 'admin' }; - userMock.get.mockResolvedValue(userStub.user1); - userMock.getByStorageLabel.mockResolvedValue(userStub.admin); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.user.getByStorageLabel.mockResolvedValue(userStub.admin); await expect(sut.update(authStub.admin, userStub.user1.id, dto)).rejects.toBeInstanceOf(BadRequestException); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('update user information should throw error if user not found', async () => { - userMock.get.mockResolvedValueOnce(void 0); + mocks.user.get.mockResolvedValueOnce(void 0); await expect( sut.update(authStub.admin, userStub.user1.id, { shouldChangePassword: true }), @@ -118,10 +114,10 @@ describe(UserAdminService.name, () => { describe('delete', () => { it('should throw error if user could not be found', async () => { - userMock.get.mockResolvedValue(void 0); + mocks.user.get.mockResolvedValue(void 0); await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toThrowError(BadRequestException); - expect(userMock.delete).not.toHaveBeenCalled(); + expect(mocks.user.delete).not.toHaveBeenCalled(); }); it('cannot delete admin user', async () => { @@ -131,33 +127,33 @@ describe(UserAdminService.name, () => { it('should require the auth user be an admin', async () => { await expect(sut.delete(authStub.user1, authStub.admin.user.id, {})).rejects.toBeInstanceOf(ForbiddenException); - expect(userMock.delete).not.toHaveBeenCalled(); + expect(mocks.user.delete).not.toHaveBeenCalled(); }); it('should delete user', async () => { - userMock.get.mockResolvedValue(userStub.user1); - userMock.update.mockResolvedValue(userStub.user1); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.user.update.mockResolvedValue(userStub.user1); await expect(sut.delete(authStub.admin, userStub.user1.id, {})).resolves.toEqual(mapUserAdmin(userStub.user1)); - expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { + expect(mocks.user.update).toHaveBeenCalledWith(userStub.user1.id, { status: UserStatus.DELETED, deletedAt: expect.any(Date), }); }); it('should force delete user', async () => { - userMock.get.mockResolvedValue(userStub.user1); - userMock.update.mockResolvedValue(userStub.user1); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.user.update.mockResolvedValue(userStub.user1); await expect(sut.delete(authStub.admin, userStub.user1.id, { force: true })).resolves.toEqual( mapUserAdmin(userStub.user1), ); - expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { + expect(mocks.user.update).toHaveBeenCalledWith(userStub.user1.id, { status: UserStatus.REMOVING, deletedAt: expect.any(Date), }); - expect(jobMock.queue).toHaveBeenCalledWith({ + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.USER_DELETION, data: { id: userStub.user1.id, force: true }, }); @@ -166,16 +162,16 @@ describe(UserAdminService.name, () => { describe('restore', () => { it('should throw error if user could not be found', async () => { - userMock.get.mockResolvedValue(void 0); + mocks.user.get.mockResolvedValue(void 0); await expect(sut.restore(authStub.admin, userStub.admin.id)).rejects.toThrowError(BadRequestException); - expect(userMock.update).not.toHaveBeenCalled(); + expect(mocks.user.update).not.toHaveBeenCalled(); }); it('should restore an user', async () => { - userMock.get.mockResolvedValue(userStub.user1); - userMock.update.mockResolvedValue(userStub.user1); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.user.restore.mockResolvedValue(userStub.user1); await expect(sut.restore(authStub.admin, userStub.user1.id)).resolves.toEqual(mapUserAdmin(userStub.user1)); - expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { status: UserStatus.ACTIVE, deletedAt: null }); + expect(mocks.user.restore).toHaveBeenCalledWith(userStub.user1.id); }); }); }); diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index a4be671c22..0cba749d36 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -10,9 +10,8 @@ import { UserAdminUpdateDto, mapUserAdmin, } from 'src/dtos/user.dto'; -import { UserMetadataKey, UserStatus } from 'src/enum'; -import { JobName } from 'src/interfaces/job.interface'; -import { UserFindOptions } from 'src/interfaces/user.interface'; +import { JobName, UserMetadataKey, UserStatus } from 'src/enum'; +import { UserFindOptions } from 'src/repositories/user.repository'; import { BaseService } from 'src/services/base.service'; import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences'; @@ -102,26 +101,29 @@ export class UserAdminService extends BaseService { async restore(auth: AuthDto, id: string): Promise { await this.findOrFail(id, { withDeleted: true }); await this.albumRepository.restoreAll(id); - const user = await this.userRepository.update(id, { deletedAt: null, status: UserStatus.ACTIVE }); + const user = await this.userRepository.restore(id); return mapUserAdmin(user); } async getPreferences(auth: AuthDto, id: string): Promise { - const user = await this.findOrFail(id, { withDeleted: false }); - const preferences = getPreferences(user); + const { email } = await this.findOrFail(id, { withDeleted: true }); + const metadata = await this.userRepository.getMetadata(id); + const preferences = getPreferences(email, metadata); return mapPreferences(preferences); } async updatePreferences(auth: AuthDto, id: string, dto: UserPreferencesUpdateDto) { - const user = await this.findOrFail(id, { withDeleted: false }); - const preferences = mergePreferences(user, dto); + const { email } = await this.findOrFail(id, { withDeleted: false }); + const metadata = await this.userRepository.getMetadata(id); + const preferences = getPreferences(email, metadata); + const newPreferences = mergePreferences(preferences, dto); - await this.userRepository.upsertMetadata(user.id, { + await this.userRepository.upsertMetadata(id, { key: UserMetadataKey.PREFERENCES, - value: getPreferencesPartial(user, preferences), + value: getPreferencesPartial({ email }, newPreferences), }); - return mapPreferences(preferences); + return mapPreferences(newPreferences); } private async findOrFail(id: string, options: UserFindOptions) { diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index cb7c2f08ad..b9fa39a8c2 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -1,18 +1,12 @@ import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { UserEntity } from 'src/entities/user.entity'; -import { CacheControl, UserMetadataKey } from 'src/enum'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IJobRepository, JobName } from 'src/interfaces/job.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { CacheControl, JobName, UserMetadataKey } from 'src/enum'; import { UserService } from 'src/services/user.service'; import { ImmichFileResponse } from 'src/utils/file'; import { authStub } from 'test/fixtures/auth.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; const makeDeletedAt = (daysAgo: number) => { const deletedAt = new Date(); @@ -22,75 +16,70 @@ const makeDeletedAt = (daysAgo: number) => { describe(UserService.name, () => { let sut: UserService; - - let albumMock: Mocked; - let jobMock: Mocked; - let storageMock: Mocked; - let systemMock: Mocked; - let userMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, albumMock, jobMock, storageMock, systemMock, userMock } = newTestService(UserService)); + ({ sut, mocks } = newTestService(UserService)); - userMock.get.mockImplementation((userId) => + mocks.user.get.mockImplementation((userId) => Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? undefined), ); }); describe('getAll', () => { it('admin should get all users', async () => { - userMock.getList.mockResolvedValue([userStub.admin]); + mocks.user.getList.mockResolvedValue([userStub.admin]); await expect(sut.search(authStub.admin)).resolves.toEqual([ expect.objectContaining({ id: authStub.admin.user.id, email: authStub.admin.user.email, }), ]); - expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: false }); + expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: false }); }); it('non-admin should get all users when publicUsers enabled', async () => { - userMock.getList.mockResolvedValue([userStub.user1]); + mocks.user.getList.mockResolvedValue([userStub.user1]); await expect(sut.search(authStub.user1)).resolves.toEqual([ expect.objectContaining({ id: authStub.user1.user.id, email: authStub.user1.user.email, }), ]); - expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: false }); + expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: false }); }); it('non-admin user should only receive itself when publicUsers is disabled', async () => { - userMock.getList.mockResolvedValue([userStub.user1]); - systemMock.get.mockResolvedValue(systemConfigStub.publicUsersDisabled); + mocks.user.getList.mockResolvedValue([userStub.user1]); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.publicUsersDisabled); await expect(sut.search(authStub.user1)).resolves.toEqual([ expect.objectContaining({ id: authStub.user1.user.id, email: authStub.user1.user.email, }), ]); - expect(userMock.getList).not.toHaveBeenCalledWith({ withDeleted: false }); + expect(mocks.user.getList).not.toHaveBeenCalledWith({ withDeleted: false }); }); }); describe('get', () => { it('should get a user by id', async () => { - userMock.get.mockResolvedValue(userStub.admin); + mocks.user.get.mockResolvedValue(userStub.admin); await sut.get(authStub.admin.user.id); - expect(userMock.get).toHaveBeenCalledWith(authStub.admin.user.id, { withDeleted: false }); + expect(mocks.user.get).toHaveBeenCalledWith(authStub.admin.user.id, { withDeleted: false }); }); it('should throw an error if a user is not found', async () => { - userMock.get.mockResolvedValue(void 0); + mocks.user.get.mockResolvedValue(void 0); await expect(sut.get(authStub.admin.user.id)).rejects.toBeInstanceOf(BadRequestException); - expect(userMock.get).toHaveBeenCalledWith(authStub.admin.user.id, { withDeleted: false }); + expect(mocks.user.get).toHaveBeenCalledWith(authStub.admin.user.id, { withDeleted: false }); }); }); describe('getMe', () => { - it("should get the auth user's info", () => { + it("should get the auth user's info", async () => { const user = authStub.admin.user; - expect(sut.getMe(authStub.admin)).toMatchObject({ + await expect(sut.getMe(authStub.admin)).resolves.toMatchObject({ id: user.id, email: user.email, }); @@ -100,78 +89,78 @@ describe(UserService.name, () => { describe('createProfileImage', () => { it('should throw an error if the user does not exist', async () => { const file = { path: '/profile/path' } as Express.Multer.File; - userMock.get.mockResolvedValue(void 0); - userMock.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path }); + mocks.user.get.mockResolvedValue(void 0); + mocks.user.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path }); await expect(sut.createProfileImage(authStub.admin, file)).rejects.toThrowError(BadRequestException); }); it('should throw an error if the user profile could not be updated with the new image', async () => { const file = { path: '/profile/path' } as Express.Multer.File; - userMock.get.mockResolvedValue(userStub.profilePath); - userMock.update.mockRejectedValue(new InternalServerErrorException('mocked error')); + mocks.user.get.mockResolvedValue(userStub.profilePath); + mocks.user.update.mockRejectedValue(new InternalServerErrorException('mocked error')); await expect(sut.createProfileImage(authStub.admin, file)).rejects.toThrowError(InternalServerErrorException); }); it('should delete the previous profile image', async () => { const file = { path: '/profile/path' } as Express.Multer.File; - userMock.get.mockResolvedValue(userStub.profilePath); + mocks.user.get.mockResolvedValue(userStub.profilePath); const files = [userStub.profilePath.profileImagePath]; - userMock.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path }); + mocks.user.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path }); await sut.createProfileImage(authStub.admin, file); - expect(jobMock.queue.mock.calls).toEqual([[{ name: JobName.DELETE_FILES, data: { files } }]]); + expect(mocks.job.queue.mock.calls).toEqual([[{ name: JobName.DELETE_FILES, data: { files } }]]); }); it('should not delete the profile image if it has not been set', async () => { const file = { path: '/profile/path' } as Express.Multer.File; - userMock.get.mockResolvedValue(userStub.admin); - userMock.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path }); + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.user.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path }); await sut.createProfileImage(authStub.admin, file); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); }); }); describe('deleteProfileImage', () => { it('should send an http error has no profile image', async () => { - userMock.get.mockResolvedValue(userStub.admin); + mocks.user.get.mockResolvedValue(userStub.admin); await expect(sut.deleteProfileImage(authStub.admin)).rejects.toBeInstanceOf(BadRequestException); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); }); it('should delete the profile image if user has one', async () => { - userMock.get.mockResolvedValue(userStub.profilePath); + mocks.user.get.mockResolvedValue(userStub.profilePath); const files = [userStub.profilePath.profileImagePath]; await sut.deleteProfileImage(authStub.admin); - expect(jobMock.queue.mock.calls).toEqual([[{ name: JobName.DELETE_FILES, data: { files } }]]); + expect(mocks.job.queue.mock.calls).toEqual([[{ name: JobName.DELETE_FILES, data: { files } }]]); }); }); describe('getUserProfileImage', () => { it('should throw an error if the user does not exist', async () => { - userMock.get.mockResolvedValue(void 0); + mocks.user.get.mockResolvedValue(void 0); await expect(sut.getProfileImage(userStub.admin.id)).rejects.toBeInstanceOf(BadRequestException); - expect(userMock.get).toHaveBeenCalledWith(userStub.admin.id, {}); + expect(mocks.user.get).toHaveBeenCalledWith(userStub.admin.id, {}); }); it('should throw an error if the user does not have a picture', async () => { - userMock.get.mockResolvedValue(userStub.admin); + mocks.user.get.mockResolvedValue(userStub.admin); await expect(sut.getProfileImage(userStub.admin.id)).rejects.toBeInstanceOf(NotFoundException); - expect(userMock.get).toHaveBeenCalledWith(userStub.admin.id, {}); + expect(mocks.user.get).toHaveBeenCalledWith(userStub.admin.id, {}); }); it('should return the profile picture', async () => { - userMock.get.mockResolvedValue(userStub.profilePath); + mocks.user.get.mockResolvedValue(userStub.profilePath); await expect(sut.getProfileImage(userStub.profilePath.id)).resolves.toEqual( new ImmichFileResponse({ @@ -181,13 +170,13 @@ describe(UserService.name, () => { }), ); - expect(userMock.get).toHaveBeenCalledWith(userStub.profilePath.id, {}); + expect(mocks.user.get).toHaveBeenCalledWith(userStub.profilePath.id, {}); }); }); describe('handleQueueUserDelete', () => { it('should skip users not ready for deletion', async () => { - userMock.getDeletedUsers.mockResolvedValue([ + mocks.user.getDeletedUsers.mockResolvedValue([ {}, { deletedAt: undefined }, { deletedAt: null }, @@ -196,14 +185,14 @@ describe(UserService.name, () => { await sut.handleUserDeleteCheck(); - expect(userMock.getDeletedUsers).toHaveBeenCalled(); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([]); + expect(mocks.user.getDeletedUsers).toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([]); }); it('should skip users not ready for deletion - deleteDelay30', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.deleteDelay30); - userMock.getDeletedUsers.mockResolvedValue([ + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.deleteDelay30); + mocks.user.getDeletedUsers.mockResolvedValue([ {}, { deletedAt: undefined }, { deletedAt: null }, @@ -212,120 +201,120 @@ describe(UserService.name, () => { await sut.handleUserDeleteCheck(); - expect(userMock.getDeletedUsers).toHaveBeenCalled(); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([]); + expect(mocks.user.getDeletedUsers).toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([]); }); it('should queue user ready for deletion', async () => { const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) }; - userMock.getDeletedUsers.mockResolvedValue([user] as UserEntity[]); + mocks.user.getDeletedUsers.mockResolvedValue([user] as UserEntity[]); await sut.handleUserDeleteCheck(); - expect(userMock.getDeletedUsers).toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.USER_DELETION, data: { id: user.id } }]); + expect(mocks.user.getDeletedUsers).toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.USER_DELETION, data: { id: user.id } }]); }); it('should queue user ready for deletion - deleteDelay30', async () => { const user = { id: 'deleted-user', deletedAt: makeDeletedAt(31) }; - userMock.getDeletedUsers.mockResolvedValue([user] as UserEntity[]); + mocks.user.getDeletedUsers.mockResolvedValue([user] as UserEntity[]); await sut.handleUserDeleteCheck(); - expect(userMock.getDeletedUsers).toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.USER_DELETION, data: { id: user.id } }]); + expect(mocks.user.getDeletedUsers).toHaveBeenCalled(); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.USER_DELETION, data: { id: user.id } }]); }); }); describe('handleUserDelete', () => { it('should skip users not ready for deletion', async () => { const user = { id: 'user-1', deletedAt: makeDeletedAt(5) } as UserEntity; - userMock.get.mockResolvedValue(user); + mocks.user.get.mockResolvedValue(user); await sut.handleUserDelete({ id: user.id }); - expect(storageMock.unlinkDir).not.toHaveBeenCalled(); - expect(userMock.delete).not.toHaveBeenCalled(); + expect(mocks.storage.unlinkDir).not.toHaveBeenCalled(); + expect(mocks.user.delete).not.toHaveBeenCalled(); }); it('should delete the user and associated assets', async () => { const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) } as UserEntity; - userMock.get.mockResolvedValue(user); + mocks.user.get.mockResolvedValue(user); await sut.handleUserDelete({ id: user.id }); const options = { force: true, recursive: true }; - expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/library/deleted-user', options); - expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/upload/deleted-user', options); - expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/profile/deleted-user', options); - expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/thumbs/deleted-user', options); - expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/encoded-video/deleted-user', options); - expect(albumMock.deleteAll).toHaveBeenCalledWith(user.id); - expect(userMock.delete).toHaveBeenCalledWith(user, true); + expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/library/deleted-user', options); + expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/upload/deleted-user', options); + expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/profile/deleted-user', options); + expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/thumbs/deleted-user', options); + expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/encoded-video/deleted-user', options); + expect(mocks.album.deleteAll).toHaveBeenCalledWith(user.id); + expect(mocks.user.delete).toHaveBeenCalledWith(user, true); }); it('should delete the library path for a storage label', async () => { const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10), storageLabel: 'admin' } as UserEntity; - userMock.get.mockResolvedValue(user); + mocks.user.get.mockResolvedValue(user); await sut.handleUserDelete({ id: user.id }); const options = { force: true, recursive: true }; - expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/library/admin', options); + expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/library/admin', options); }); }); describe('setLicense', () => { it('should save client license if valid', async () => { - userMock.upsertMetadata.mockResolvedValue(); + mocks.user.upsertMetadata.mockResolvedValue(); const license = { licenseKey: 'IMCL-license-key', activationKey: 'activation-key' }; await sut.setLicense(authStub.user1, license); - expect(userMock.upsertMetadata).toHaveBeenCalledWith(authStub.user1.user.id, { + expect(mocks.user.upsertMetadata).toHaveBeenCalledWith(authStub.user1.user.id, { key: UserMetadataKey.LICENSE, value: expect.any(Object), }); }); it('should save server license as client if valid', async () => { - userMock.upsertMetadata.mockResolvedValue(); + mocks.user.upsertMetadata.mockResolvedValue(); const license = { licenseKey: 'IMSV-license-key', activationKey: 'activation-key' }; await sut.setLicense(authStub.user1, license); - expect(userMock.upsertMetadata).toHaveBeenCalledWith(authStub.user1.user.id, { + expect(mocks.user.upsertMetadata).toHaveBeenCalledWith(authStub.user1.user.id, { key: UserMetadataKey.LICENSE, value: expect.any(Object), }); }); it('should not save license if invalid', async () => { - userMock.upsertMetadata.mockResolvedValue(); + mocks.user.upsertMetadata.mockResolvedValue(); const license = { licenseKey: 'license-key', activationKey: 'activation-key' }; const call = sut.setLicense(authStub.admin, license); await expect(call).rejects.toThrowError('Invalid license key'); - expect(userMock.upsertMetadata).not.toHaveBeenCalled(); + expect(mocks.user.upsertMetadata).not.toHaveBeenCalled(); }); }); describe('deleteLicense', () => { it('should delete license', async () => { - userMock.upsertMetadata.mockResolvedValue(); + mocks.user.upsertMetadata.mockResolvedValue(); await sut.deleteLicense(authStub.admin); - expect(userMock.upsertMetadata).not.toHaveBeenCalled(); + expect(mocks.user.upsertMetadata).not.toHaveBeenCalled(); }); }); describe('handleUserSyncUsage', () => { it('should sync usage', async () => { await sut.handleUserSyncUsage(); - expect(userMock.syncUsage).toHaveBeenCalledTimes(1); + expect(mocks.user.syncUsage).toHaveBeenCalledTimes(1); }); }); }); diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index f4ae42b5ed..f7d6018207 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -10,10 +10,10 @@ import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto'; import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto'; import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; -import { CacheControl, StorageFolder, UserMetadataKey } from 'src/enum'; -import { JobName, JobOf, JobStatus, QueueName } from 'src/interfaces/job.interface'; -import { UserFindOptions } from 'src/interfaces/user.interface'; +import { CacheControl, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum'; +import { UserFindOptions } from 'src/repositories/user.repository'; import { BaseService } from 'src/services/base.service'; +import { JobOf } from 'src/types'; import { ImmichFileResponse } from 'src/utils/file'; import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences'; @@ -22,16 +22,24 @@ export class UserService extends BaseService { async search(auth: AuthDto): Promise { const config = await this.getConfig({ withCache: false }); - let users: UserEntity[] = [auth.user]; + let users; if (auth.user.isAdmin || config.server.publicUsers) { users = await this.userRepository.getList({ withDeleted: false }); + } else { + const authUser = await this.userRepository.get(auth.user.id, {}); + users = authUser ? [authUser] : []; } return users.map((user) => mapUser(user)); } - getMe(auth: AuthDto): UserAdminResponseDto { - return mapUserAdmin(auth.user); + async getMe(auth: AuthDto): Promise { + const user = await this.userRepository.get(auth.user.id, {}); + if (!user) { + throw new BadRequestException('User not found'); + } + + return mapUserAdmin(user); } async updateMe({ user }: AuthDto, dto: UserUpdateMeDto): Promise { @@ -58,20 +66,23 @@ export class UserService extends BaseService { return mapUserAdmin(updatedUser); } - getMyPreferences({ user }: AuthDto): UserPreferencesResponseDto { - const preferences = getPreferences(user); + async getMyPreferences(auth: AuthDto): Promise { + const metadata = await this.userRepository.getMetadata(auth.user.id); + const preferences = getPreferences(auth.user.email, metadata); return mapPreferences(preferences); } - async updateMyPreferences({ user }: AuthDto, dto: UserPreferencesUpdateDto) { - const preferences = mergePreferences(user, dto); + async updateMyPreferences(auth: AuthDto, dto: UserPreferencesUpdateDto) { + const metadata = await this.userRepository.getMetadata(auth.user.id); + const current = getPreferences(auth.user.email, metadata); + const updated = mergePreferences(current, dto); - await this.userRepository.upsertMetadata(user.id, { + await this.userRepository.upsertMetadata(auth.user.id, { key: UserMetadataKey.PREFERENCES, - value: getPreferencesPartial(user, preferences), + value: getPreferencesPartial(auth.user, updated), }); - return mapPreferences(preferences); + return mapPreferences(updated); } async get(id: string): Promise { @@ -120,14 +131,16 @@ export class UserService extends BaseService { }); } - getLicense({ user }: AuthDto): LicenseResponseDto { - const license = user.metadata.find( + async getLicense(auth: AuthDto): Promise { + const metadata = await this.userRepository.getMetadata(auth.user.id); + + const license = metadata.find( (item): item is UserMetadataEntity => item.key === UserMetadataKey.LICENSE, ); if (!license) { throw new NotFoundException(); } - return license.value; + return { ...license.value, activatedAt: new Date(license.value.activatedAt) }; } async deleteLicense({ user }: AuthDto): Promise { @@ -157,17 +170,14 @@ export class UserService extends BaseService { throw new BadRequestException('Invalid license key'); } - const licenseData = { - ...license, - activatedAt: new Date(), - }; + const activatedAt = new Date(); await this.userRepository.upsertMetadata(auth.user.id, { key: UserMetadataKey.LICENSE, - value: licenseData, + value: { ...license, activatedAt: activatedAt.toISOString() }, }); - return licenseData; + return { ...license, activatedAt }; } @OnJob({ name: JobName.USER_SYNC_USAGE, queue: QueueName.BACKGROUND_TASK }) diff --git a/server/src/services/version.service.spec.ts b/server/src/services/version.service.spec.ts index 406d3c1439..a83d9f85b6 100644 --- a/server/src/services/version.service.spec.ts +++ b/server/src/services/version.service.spec.ts @@ -1,15 +1,11 @@ import { DateTime } from 'luxon'; import { SemVer } from 'semver'; import { serverVersion } from 'src/constants'; -import { ImmichEnvironment, SystemMetadataKey } from 'src/enum'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { ImmichEnvironment, JobName, JobStatus, SystemMetadataKey } from 'src/enum'; import { VersionService } from 'src/services/version.service'; -import { IConfigRepository, ILoggingRepository, IServerInfoRepository, IVersionHistoryRepository } from 'src/types'; import { mockEnvData } from 'test/repositories/config.repository.mock'; -import { newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { factory } from 'test/small.factory'; +import { newTestService, ServiceMocks } from 'test/utils'; const mockRelease = (version: string) => ({ id: 1, @@ -23,18 +19,10 @@ const mockRelease = (version: string) => ({ describe(VersionService.name, () => { let sut: VersionService; - - let configMock: Mocked; - let eventMock: Mocked; - let jobMock: Mocked; - let loggerMock: Mocked; - let serverInfoMock: Mocked; - let systemMock: Mocked; - let versionHistoryMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, configMock, eventMock, jobMock, loggerMock, serverInfoMock, systemMock, versionHistoryMock } = - newTestService(VersionService)); + ({ sut, mocks } = newTestService(VersionService)); }); it('should work', () => { @@ -43,18 +31,23 @@ describe(VersionService.name, () => { describe('onBootstrap', () => { it('should record a new version', async () => { + mocks.versionHistory.getAll.mockResolvedValue([]); + mocks.versionHistory.getLatest.mockResolvedValue(void 0); + mocks.versionHistory.create.mockResolvedValue(factory.versionHistory()); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(versionHistoryMock.create).toHaveBeenCalledWith({ version: expect.any(String) }); + + expect(mocks.versionHistory.create).toHaveBeenCalledWith({ version: expect.any(String) }); }); it('should skip a duplicate version', async () => { - versionHistoryMock.getLatest.mockResolvedValue({ + mocks.versionHistory.getLatest.mockResolvedValue({ id: 'version-1', createdAt: new Date(), version: serverVersion.toString(), }); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(versionHistoryMock.create).not.toHaveBeenCalled(); + expect(mocks.versionHistory.create).not.toHaveBeenCalled(); }); }); @@ -71,7 +64,7 @@ describe(VersionService.name, () => { describe('getVersionHistory', () => { it('should respond the server version history', async () => { const upgrade = { id: 'upgrade-1', createdAt: new Date(), version: '1.0.0' }; - versionHistoryMock.getAll.mockResolvedValue([upgrade]); + mocks.versionHistory.getAll.mockResolvedValue([upgrade]); await expect(sut.getVersionHistory()).resolves.toEqual([upgrade]); }); }); @@ -79,22 +72,22 @@ describe(VersionService.name, () => { describe('handQueueVersionCheck', () => { it('should queue a version check job', async () => { await expect(sut.handleQueueVersionCheck()).resolves.toBeUndefined(); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VERSION_CHECK, data: {} }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.VERSION_CHECK, data: {} }); }); }); describe('handVersionCheck', () => { beforeEach(() => { - configMock.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.PRODUCTION })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.PRODUCTION })); }); it('should not run in dev mode', async () => { - configMock.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.DEVELOPMENT })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.DEVELOPMENT })); await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SKIPPED); }); it('should not run if the last check was < 60 minutes ago', async () => { - systemMock.get.mockResolvedValue({ + mocks.systemMetadata.get.mockResolvedValue({ checkedAt: DateTime.utc().minus({ minutes: 5 }).toISO(), releaseVersion: '1.0.0', }); @@ -102,53 +95,53 @@ describe(VersionService.name, () => { }); it('should not run if version check is disabled', async () => { - systemMock.get.mockResolvedValue({ newVersionCheck: { enabled: false } }); + mocks.systemMetadata.get.mockResolvedValue({ newVersionCheck: { enabled: false } }); await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SKIPPED); }); it('should run if it has been > 60 minutes', async () => { - serverInfoMock.getGitHubRelease.mockResolvedValue(mockRelease('v100.0.0')); - systemMock.get.mockResolvedValue({ + mocks.serverInfo.getGitHubRelease.mockResolvedValue(mockRelease('v100.0.0')); + mocks.systemMetadata.get.mockResolvedValue({ checkedAt: DateTime.utc().minus({ minutes: 65 }).toISO(), releaseVersion: '1.0.0', }); await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SUCCESS); - expect(systemMock.set).toHaveBeenCalled(); - expect(loggerMock.log).toHaveBeenCalled(); - expect(eventMock.clientBroadcast).toHaveBeenCalled(); + expect(mocks.systemMetadata.set).toHaveBeenCalled(); + expect(mocks.logger.log).toHaveBeenCalled(); + expect(mocks.event.clientBroadcast).toHaveBeenCalled(); }); it('should not notify if the version is equal', async () => { - serverInfoMock.getGitHubRelease.mockResolvedValue(mockRelease(serverVersion.toString())); + mocks.serverInfo.getGitHubRelease.mockResolvedValue(mockRelease(serverVersion.toString())); await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SUCCESS); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.VERSION_CHECK_STATE, { + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.VERSION_CHECK_STATE, { checkedAt: expect.any(String), releaseVersion: serverVersion.toString(), }); - expect(eventMock.clientBroadcast).not.toHaveBeenCalled(); + expect(mocks.event.clientBroadcast).not.toHaveBeenCalled(); }); it('should handle a github error', async () => { - serverInfoMock.getGitHubRelease.mockRejectedValue(new Error('GitHub is down')); + mocks.serverInfo.getGitHubRelease.mockRejectedValue(new Error('GitHub is down')); await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.FAILED); - expect(systemMock.set).not.toHaveBeenCalled(); - expect(eventMock.clientBroadcast).not.toHaveBeenCalled(); - expect(loggerMock.warn).toHaveBeenCalled(); + expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); + expect(mocks.event.clientBroadcast).not.toHaveBeenCalled(); + expect(mocks.logger.warn).toHaveBeenCalled(); }); }); describe('onWebsocketConnectionEvent', () => { it('should send on_server_version client event', async () => { await sut.onWebsocketConnection({ userId: '42' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer)); - expect(eventMock.clientSend).toHaveBeenCalledTimes(1); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer)); + expect(mocks.event.clientSend).toHaveBeenCalledTimes(1); }); it('should also send a new release notification', async () => { - systemMock.get.mockResolvedValue({ checkedAt: '2024-01-01', releaseVersion: 'v1.42.0' }); + mocks.systemMetadata.get.mockResolvedValue({ checkedAt: '2024-01-01', releaseVersion: 'v1.42.0' }); await sut.onWebsocketConnection({ userId: '42' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer)); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_new_release', '42', expect.any(Object)); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer)); + expect(mocks.event.clientSend).toHaveBeenCalledWith('on_new_release', '42', expect.any(Object)); }); }); }); diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index ff4fa3c6bf..ee28a20d4d 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -5,10 +5,8 @@ import { serverVersion } from 'src/constants'; import { OnEvent, OnJob } from 'src/decorators'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { VersionCheckMetadata } from 'src/entities/system-metadata.entity'; -import { ImmichEnvironment, SystemMetadataKey } from 'src/enum'; -import { DatabaseLock } from 'src/interfaces/database.interface'; -import { ArgOf } from 'src/interfaces/event.interface'; -import { JobName, JobStatus, QueueName } from 'src/interfaces/job.interface'; +import { DatabaseLock, ImmichEnvironment, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum'; +import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => { @@ -27,11 +25,24 @@ export class VersionService extends BaseService { await this.handleVersionCheck(); await this.databaseRepository.withLock(DatabaseLock.VersionHistory, async () => { - const latest = await this.versionRepository.getLatest(); + const previous = await this.versionRepository.getLatest(); const current = serverVersion.toString(); - if (!latest || latest.version !== current) { - this.logger.log(`Version has changed, adding ${current} to history`); + + if (!previous) { await this.versionRepository.create({ version: current }); + return; + } + + if (previous.version !== current) { + const previousVersion = new SemVer(previous.version); + + this.logger.log(`Adding ${current} to upgrade history`); + await this.versionRepository.create({ version: current }); + + const needsNewMemories = semver.lt(previousVersion, '1.129.0'); + if (needsNewMemories) { + await this.jobRepository.queue({ name: JobName.MEMORIES_CREATE }); + } } }); } diff --git a/server/src/services/view.service.spec.ts b/server/src/services/view.service.spec.ts index e033ec0dc8..86bfcef734 100644 --- a/server/src/services/view.service.spec.ts +++ b/server/src/services/view.service.spec.ts @@ -1,18 +1,15 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { ViewService } from 'src/services/view.service'; -import { IViewRepository } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { newTestService } from 'test/utils'; - -import { Mocked } from 'vitest'; +import { newTestService, ServiceMocks } from 'test/utils'; describe(ViewService.name, () => { let sut: ViewService; - let viewMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, viewMock } = newTestService(ViewService)); + ({ sut, mocks } = newTestService(ViewService)); }); it('should work', () => { @@ -22,12 +19,12 @@ describe(ViewService.name, () => { describe('getUniqueOriginalPaths', () => { it('should return unique original paths', async () => { const mockPaths = ['path1', 'path2', 'path3']; - viewMock.getUniqueOriginalPaths.mockResolvedValue(mockPaths); + mocks.view.getUniqueOriginalPaths.mockResolvedValue(mockPaths); const result = await sut.getUniqueOriginalPaths(authStub.admin); expect(result).toEqual(mockPaths); - expect(viewMock.getUniqueOriginalPaths).toHaveBeenCalledWith(authStub.admin.user.id); + expect(mocks.view.getUniqueOriginalPaths).toHaveBeenCalledWith(authStub.admin.user.id); }); }); @@ -42,11 +39,11 @@ describe(ViewService.name, () => { const mockAssetReponseDto = mockAssets.map((a) => mapAsset(a, { auth: authStub.admin })); - viewMock.getAssetsByOriginalPath.mockResolvedValue(mockAssets as any); + mocks.view.getAssetsByOriginalPath.mockResolvedValue(mockAssets as any); const result = await sut.getAssetsByOriginalPath(authStub.admin, path); expect(result).toEqual(mockAssetReponseDto); - await expect(viewMock.getAssetsByOriginalPath(authStub.admin.user.id, path)).resolves.toEqual(mockAssets); + await expect(mocks.view.getAssetsByOriginalPath(authStub.admin.user.id, path)).resolves.toEqual(mockAssets); }); }); }); diff --git a/server/src/services/view.service.ts b/server/src/services/view.service.ts index f1ef40a810..5871b04b32 100644 --- a/server/src/services/view.service.ts +++ b/server/src/services/view.service.ts @@ -1,8 +1,10 @@ +import { Injectable } from '@nestjs/common'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { BaseService } from 'src/services/base.service'; +@Injectable() export class ViewService extends BaseService { getUniqueOriginalPaths(auth: AuthDto): Promise { return this.viewRepository.getUniqueOriginalPaths(auth.user.id); diff --git a/server/src/types.ts b/server/src/types.ts index 9928669136..1c0a61b259 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,68 +1,27 @@ -import { UserEntity } from 'src/entities/user.entity'; -import { ExifOrientation, ImageFormat, Permission, TranscodeTarget, VideoCodec } from 'src/enum'; -import { AccessRepository } from 'src/repositories/access.repository'; +import { + AssetType, + DatabaseExtension, + ExifOrientation, + ImageFormat, + JobName, + QueueName, + SyncEntityType, + TranscodeTarget, + VideoCodec, +} from 'src/enum'; import { ActivityRepository } from 'src/repositories/activity.repository'; -import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; -import { AuditRepository } from 'src/repositories/audit.repository'; -import { ConfigRepository } from 'src/repositories/config.repository'; -import { CronRepository } from 'src/repositories/cron.repository'; -import { LoggingRepository } from 'src/repositories/logging.repository'; -import { MapRepository } from 'src/repositories/map.repository'; -import { MediaRepository } from 'src/repositories/media.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; -import { MetadataRepository } from 'src/repositories/metadata.repository'; -import { NotificationRepository } from 'src/repositories/notification.repository'; -import { OAuthRepository } from 'src/repositories/oauth.repository'; -import { ServerInfoRepository } from 'src/repositories/server-info.repository'; -import { MetricGroupRepository, TelemetryRepository } from 'src/repositories/telemetry.repository'; -import { TrashRepository } from 'src/repositories/trash.repository'; -import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; -import { ViewRepository } from 'src/repositories/view-repository'; +import { SessionRepository } from 'src/repositories/session.repository'; export type DeepPartial = T extends object ? { [K in keyof T]?: DeepPartial } : T; -export type AuthApiKey = { - id: string; - key: string; - user: UserEntity; - permissions: Permission[]; -}; - export type RepositoryInterface = Pick; -export type IActivityRepository = RepositoryInterface; -export type IAccessRepository = { [K in keyof AccessRepository]: RepositoryInterface }; -export type IAlbumUserRepository = RepositoryInterface; -export type IApiKeyRepository = RepositoryInterface; -export type IAuditRepository = RepositoryInterface; -export type IConfigRepository = RepositoryInterface; -export type ICronRepository = RepositoryInterface; -export type ILoggingRepository = Pick< - LoggingRepository, - | 'verbose' - | 'log' - | 'debug' - | 'warn' - | 'error' - | 'fatal' - | 'isLevelEnabled' - | 'setLogLevel' - | 'setContext' - | 'setAppName' ->; -export type IMapRepository = RepositoryInterface; -export type IMediaRepository = RepositoryInterface; -export type IMemoryRepository = RepositoryInterface; -export type IMetadataRepository = RepositoryInterface; -export type IMetricGroupRepository = RepositoryInterface; -export type INotificationRepository = RepositoryInterface; -export type IOAuthRepository = RepositoryInterface; -export type IServerInfoRepository = RepositoryInterface; -export type ITelemetryRepository = RepositoryInterface; -export type ITrashRepository = RepositoryInterface; -export type IViewRepository = RepositoryInterface; -export type IVersionHistoryRepository = RepositoryInterface; +type IActivityRepository = RepositoryInterface; +type IApiKeyRepository = RepositoryInterface; +type IMemoryRepository = RepositoryInterface; +type ISessionRepository = RepositoryInterface; export type ActivityItem = | Awaited> @@ -77,6 +36,17 @@ export type MemoryItem = | Awaited> | Awaited>[0]; +export type SessionItem = Awaited>[0]; + +export type TagItem = { + id: string; + value: string; + createdAt: Date; + updatedAt: Date; + color: string | null; + parentId: string | null; +}; + export interface CropOptions { top: number; left: number; @@ -208,3 +178,279 @@ export interface VideoInterfaces { dri: string[]; mali: boolean; } + +export type ConcurrentQueueName = Exclude< + QueueName, + | QueueName.STORAGE_TEMPLATE_MIGRATION + | QueueName.FACIAL_RECOGNITION + | QueueName.DUPLICATE_DETECTION + | QueueName.BACKUP_DATABASE +>; + +export type Jobs = { [K in JobItem['name']]: (JobItem & { name: K })['data'] }; +export type JobOf = Jobs[T]; + +export interface IBaseJob { + force?: boolean; +} + +export interface IDelayedJob extends IBaseJob { + /** The minimum time to wait to execute this job, in milliseconds. */ + delay?: number; +} + +export interface IEntityJob extends IBaseJob { + id: string; + source?: 'upload' | 'sidecar-write' | 'copy'; + notify?: boolean; +} + +export interface IAssetDeleteJob extends IEntityJob { + deleteOnDisk: boolean; +} + +export interface ILibraryFileJob { + libraryId: string; + paths: string[]; + progressCounter?: number; + totalAssets?: number; +} + +export interface ILibraryBulkIdsJob { + libraryId: string; + importPaths: string[]; + exclusionPatterns: string[]; + assetIds: string[]; + progressCounter: number; + totalAssets: number; +} + +export interface IBulkEntityJob { + ids: string[]; +} + +export interface IDeleteFilesJob extends IBaseJob { + files: Array; +} + +export interface ISidecarWriteJob extends IEntityJob { + description?: string; + dateTimeOriginal?: string; + latitude?: number; + longitude?: number; + rating?: number; + tags?: true; +} + +export interface IDeferrableJob extends IEntityJob { + deferred?: boolean; +} + +export interface INightlyJob extends IBaseJob { + nightly?: boolean; +} + +export type EmailImageAttachment = { + filename: string; + path: string; + cid: string; +}; + +export interface IEmailJob { + to: string; + subject: string; + html: string; + text: string; + imageAttachments?: EmailImageAttachment[]; +} + +export interface INotifySignupJob extends IEntityJob { + tempPassword?: string; +} + +export interface INotifyAlbumInviteJob extends IEntityJob { + recipientId: string; +} + +export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob { + recipientIds: string[]; +} + +export interface JobCounts { + active: number; + completed: number; + failed: number; + delayed: number; + waiting: number; + paused: number; +} + +export interface QueueStatus { + isActive: boolean; + isPaused: boolean; +} + +export type JobItem = + // Backups + | { name: JobName.BACKUP_DATABASE; data?: IBaseJob } + + // Transcoding + | { name: JobName.QUEUE_VIDEO_CONVERSION; data: IBaseJob } + | { name: JobName.VIDEO_CONVERSION; data: IEntityJob } + + // Thumbnails + | { name: JobName.QUEUE_GENERATE_THUMBNAILS; data: IBaseJob } + | { name: JobName.GENERATE_THUMBNAILS; data: IEntityJob } + + // User + | { name: JobName.USER_DELETE_CHECK; data?: IBaseJob } + | { name: JobName.USER_DELETION; data: IEntityJob } + | { name: JobName.USER_SYNC_USAGE; data?: IBaseJob } + + // Storage Template + | { name: JobName.STORAGE_TEMPLATE_MIGRATION; data?: IBaseJob } + | { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE; data: IEntityJob } + + // Migration + | { name: JobName.QUEUE_MIGRATION; data?: IBaseJob } + | { name: JobName.MIGRATE_ASSET; data: IEntityJob } + | { name: JobName.MIGRATE_PERSON; data: IEntityJob } + + // Metadata Extraction + | { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob } + | { name: JobName.METADATA_EXTRACTION; data: IEntityJob } + // Sidecar Scanning + | { name: JobName.QUEUE_SIDECAR; data: IBaseJob } + | { name: JobName.SIDECAR_DISCOVERY; data: IEntityJob } + | { name: JobName.SIDECAR_SYNC; data: IEntityJob } + | { name: JobName.SIDECAR_WRITE; data: ISidecarWriteJob } + + // Facial Recognition + | { name: JobName.QUEUE_FACE_DETECTION; data: IBaseJob } + | { name: JobName.FACE_DETECTION; data: IEntityJob } + | { name: JobName.QUEUE_FACIAL_RECOGNITION; data: INightlyJob } + | { name: JobName.FACIAL_RECOGNITION; data: IDeferrableJob } + | { name: JobName.GENERATE_PERSON_THUMBNAIL; data: IEntityJob } + + // Smart Search + | { name: JobName.QUEUE_SMART_SEARCH; data: IBaseJob } + | { name: JobName.SMART_SEARCH; data: IEntityJob } + | { name: JobName.QUEUE_TRASH_EMPTY; data?: IBaseJob } + + // Duplicate Detection + | { name: JobName.QUEUE_DUPLICATE_DETECTION; data: IBaseJob } + | { name: JobName.DUPLICATE_DETECTION; data: IEntityJob } + + // Memories + | { name: JobName.MEMORIES_CLEANUP; data?: IBaseJob } + | { name: JobName.MEMORIES_CREATE; data?: IBaseJob } + + // Filesystem + | { name: JobName.DELETE_FILES; data: IDeleteFilesJob } + + // Cleanup + | { name: JobName.CLEAN_OLD_AUDIT_LOGS; data?: IBaseJob } + | { name: JobName.CLEAN_OLD_SESSION_TOKENS; data?: IBaseJob } + + // Tags + | { name: JobName.TAG_CLEANUP; data?: IBaseJob } + + // Asset Deletion + | { name: JobName.PERSON_CLEANUP; data?: IBaseJob } + | { name: JobName.ASSET_DELETION; data: IAssetDeleteJob } + | { name: JobName.ASSET_DELETION_CHECK; data?: IBaseJob } + + // Library Management + | { name: JobName.LIBRARY_SYNC_FILES; data: ILibraryFileJob } + | { name: JobName.LIBRARY_QUEUE_SYNC_FILES; data: IEntityJob } + | { name: JobName.LIBRARY_QUEUE_SYNC_ASSETS; data: IEntityJob } + | { name: JobName.LIBRARY_SYNC_ASSETS; data: ILibraryBulkIdsJob } + | { name: JobName.LIBRARY_ASSET_REMOVAL; data: ILibraryFileJob } + | { name: JobName.LIBRARY_DELETE; data: IEntityJob } + | { name: JobName.LIBRARY_QUEUE_SCAN_ALL; data?: IBaseJob } + | { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob } + + // Notification + | { name: JobName.SEND_EMAIL; data: IEmailJob } + | { name: JobName.NOTIFY_ALBUM_INVITE; data: INotifyAlbumInviteJob } + | { name: JobName.NOTIFY_ALBUM_UPDATE; data: INotifyAlbumUpdateJob } + | { name: JobName.NOTIFY_SIGNUP; data: INotifySignupJob } + + // Version check + | { name: JobName.VERSION_CHECK; data: IBaseJob } + + // Memories + | { name: JobName.MEMORIES_CLEANUP; data?: IBaseJob } + | { name: JobName.MEMORIES_CREATE; data?: IBaseJob }; + +export type VectorExtension = DatabaseExtension.VECTOR | DatabaseExtension.VECTORS; + +export type DatabaseConnectionURL = { + connectionType: 'url'; + url: string; +}; + +export type DatabaseConnectionParts = { + connectionType: 'parts'; + host: string; + port: number; + username: string; + password: string; + database: string; +}; + +export type DatabaseConnectionParams = DatabaseConnectionURL | DatabaseConnectionParts; + +export interface ExtensionVersion { + availableVersion: string | null; + installedVersion: string | null; +} + +export interface VectorUpdateResult { + restartRequired: boolean; +} + +export interface ImmichFile extends Express.Multer.File { + /** sha1 hash of file */ + uuid: string; + checksum: Buffer; +} + +export interface UploadFile { + uuid: string; + checksum: Buffer; + originalPath: string; + originalName: string; + size: number; +} + +export interface UploadFiles { + assetData: ImmichFile[]; + sidecarData: ImmichFile[]; +} + +export interface IBulkAsset { + getAssetIds: (id: string, assetIds: string[]) => Promise>; + addAssetIds: (id: string, assetIds: string[]) => Promise; + removeAssetIds: (id: string, assetIds: string[]) => Promise; +} + +export type SyncAck = { + type: SyncEntityType; + updateId: string; +}; + +export type StorageAsset = { + id: string; + ownerId: string; + livePhotoVideoId: string | null; + type: AssetType; + isExternal: boolean; + checksum: Buffer; + timeZone: string | null; + fileCreatedAt: Date; + originalPath: string; + originalFileName: string; + sidecarPath: string | null; + fileSizeInByte: number | null; +}; diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index cb91737349..e88c8e1a63 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -1,6 +1,6 @@ import { BadRequestException, UnauthorizedException } from '@nestjs/common'; +import { AuthSharedLink } from 'src/database'; import { AuthDto } from 'src/dtos/auth.dto'; -import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { AlbumUserRole, Permission } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { setDifference, setIsEqual, setIsSuperset, setUnion } from 'src/utils/set'; @@ -24,7 +24,7 @@ export type AccessRequest = { ids: Set | string[]; }; -type SharedLinkAccessRequest = { sharedLink: SharedLinkEntity; permission: Permission; ids: Set }; +type SharedLinkAccessRequest = { sharedLink: AuthSharedLink; permission: Permission; ids: Set }; type OtherAccessRequest = { auth: AuthDto; permission: Permission; ids: Set }; export const requireUploadAccess = (auth: AuthDto | null): AuthDto => { @@ -217,6 +217,10 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return await access.authDevice.checkOwnerAccess(auth.user.id, ids); } + case Permission.FACE_DELETE: { + return access.person.checkFaceOwnerAccess(auth.user.id, ids); + } + case Permission.TAG_ASSET: case Permission.TAG_READ: case Permission.TAG_UPDATE: diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index 39593a77f3..de64720a82 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -5,21 +5,14 @@ import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileType, AssetType, Permission } from 'src/enum'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { AuthRequest } from 'src/middleware/auth.guard'; -import { ImmichFile } from 'src/middleware/file-upload.interceptor'; import { AccessRepository } from 'src/repositories/access.repository'; -import { UploadFile } from 'src/services/asset-media.service'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { EventRepository } from 'src/repositories/event.repository'; +import { PartnerRepository } from 'src/repositories/partner.repository'; +import { IBulkAsset, ImmichFile, UploadFile } from 'src/types'; import { checkAccess } from 'src/utils/access'; -export interface IBulkAsset { - getAssetIds: (id: string, assetIds: string[]) => Promise>; - addAssetIds: (id: string, assetIds: string[]) => Promise; - removeAssetIds: (id: string, assetIds: string[]) => Promise; -} - const getFileByType = (files: AssetFileEntity[] | undefined, type: AssetFileType) => { return (files || []).find((file) => file.type === type); }; @@ -111,7 +104,7 @@ export const removeAssets = async ( export type PartnerIdOptions = { userId: string; - repository: IPartnerRepository; + repository: PartnerRepository; /** only include partners with `inTimeline: true` */ timelineEnabled?: boolean; }; @@ -139,7 +132,7 @@ export const getMyPartnerIds = async ({ userId, repository, timelineEnabled }: P return [...partnerIds]; }; -export type AssetHookRepositories = { asset: IAssetRepository; event: IEventRepository }; +export type AssetHookRepositories = { asset: AssetRepository; event: EventRepository }; export const onBeforeLink = async ( { asset: assetRepository, event: eventRepository }: AssetHookRepositories, diff --git a/server/src/utils/config.ts b/server/src/utils/config.ts index cd28c63618..4dee1c348e 100644 --- a/server/src/utils/config.ts +++ b/server/src/utils/config.ts @@ -5,18 +5,19 @@ import { load as loadYaml } from 'js-yaml'; import * as _ from 'lodash'; import { SystemConfig, defaults } from 'src/config'; import { SystemConfigDto } from 'src/dtos/system-config.dto'; -import { SystemMetadataKey } from 'src/enum'; -import { DatabaseLock } from 'src/interfaces/database.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { DeepPartial, IConfigRepository, ILoggingRepository } from 'src/types'; +import { DatabaseLock, SystemMetadataKey } from 'src/enum'; +import { ConfigRepository } from 'src/repositories/config.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; +import { DeepPartial } from 'src/types'; import { getKeysDeep, unsetDeep } from 'src/utils/misc'; export type SystemConfigValidator = (config: SystemConfig, newConfig: SystemConfig) => void | Promise; type RepoDeps = { - configRepo: IConfigRepository; - metadataRepo: ISystemMetadataRepository; - logger: ILoggingRepository; + configRepo: ConfigRepository; + metadataRepo: SystemMetadataRepository; + logger: LoggingRepository; }; const asyncLock = new AsyncLock(); diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 7483ef6f92..456165063c 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -1,6 +1,4 @@ -import { Expression, RawBuilder, sql, ValueExpression } from 'kysely'; -import { InsertObject } from 'node_modules/kysely/dist/cjs'; -import { DB } from 'src/db'; +import { Expression, sql } from 'kysely'; import { Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; /** @@ -17,33 +15,24 @@ export function OptionalBetween(from?: T, to?: T) { } } -// populated by the database repository at bootstrap -export const UPSERT_COLUMNS = {} as { [T in keyof DB]: { [K in keyof DB[T]]: RawBuilder } }; - -/** Generates the columns for an upsert statement, excluding the conflict keys. - * Assumes that all entries have the same keys. */ -export function mapUpsertColumns( - table: T, - entry: InsertObject, - conflictKeys: readonly (keyof DB[T])[], -) { - const columns = UPSERT_COLUMNS[table] as { [K in keyof DB[T]]: RawBuilder }; - const upsertColumns: Partial>> = {}; - for (const entryColumn in entry) { - if (!conflictKeys.includes(entryColumn as keyof DB[T])) { - upsertColumns[entryColumn as keyof typeof entry] = columns[entryColumn as keyof DB[T]]; - } - } - - return upsertColumns as Expand>>; -} - export const asUuid = (id: string | Expression) => sql`${id}::uuid`; 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) => { + for (const key in update) { + if ((template as T)[key] === undefined) { + delete update[key]; + } + } + + return update; +}; + /** * Mainly for type debugging to make VS Code display a more useful tooltip. * Source: https://stackoverflow.com/a/69288824 diff --git a/server/src/utils/date-time.ts b/server/src/utils/date-time.ts deleted file mode 100644 index e1578cbb19..0000000000 --- a/server/src/utils/date-time.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { AssetEntity } from 'src/entities/asset.entity'; - -export const getAssetDateTime = (asset: AssetEntity | undefined) => { - return asset?.exifInfo?.dateTimeOriginal || asset?.fileCreatedAt; -}; diff --git a/server/src/utils/date.ts b/server/src/utils/date.ts new file mode 100644 index 0000000000..67ce549050 --- /dev/null +++ b/server/src/utils/date.ts @@ -0,0 +1,3 @@ +export const asDateString = (x: Date | string | null): string | null => { + return x instanceof Date ? x.toISOString().split('T')[0] : x; +}; diff --git a/server/src/utils/file.ts b/server/src/utils/file.ts index 4f3009e39f..716e0b1957 100644 --- a/server/src/utils/file.ts +++ b/server/src/utils/file.ts @@ -4,8 +4,8 @@ import { access, constants } from 'node:fs/promises'; import { basename, extname, isAbsolute } from 'node:path'; import { promisify } from 'node:util'; import { CacheControl } from 'src/enum'; -import { ImmichReadStream } from 'src/interfaces/storage.interface'; -import { ILoggingRepository } from 'src/types'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { ImmichReadStream } from 'src/repositories/storage.repository'; import { isConnectionAborted } from 'src/utils/misc'; export function getFileNameWithoutExtension(path: string): string { @@ -33,27 +33,28 @@ export class ImmichFileResponse { type SendFile = Parameters; type SendFileOptions = SendFile[1]; +const cacheControlHeaders: Record = { + [CacheControl.PRIVATE_WITH_CACHE]: 'private, max-age=86400, no-transform', + [CacheControl.PRIVATE_WITHOUT_CACHE]: 'private, no-cache, no-transform', + [CacheControl.NONE]: null, // falsy value to prevent adding Cache-Control header +}; + export const sendFile = async ( res: Response, next: NextFunction, handler: () => Promise, - logger: ILoggingRepository, + logger: LoggingRepository, ): Promise => { + // promisified version of 'res.sendFile' for cleaner async handling const _sendFile = (path: string, options: SendFileOptions) => promisify(res.sendFile).bind(res)(path, options); try { const file = await handler(); - switch (file.cacheControl) { - case CacheControl.PRIVATE_WITH_CACHE: { - res.set('Cache-Control', 'private, max-age=86400, no-transform'); - break; - } - - case CacheControl.PRIVATE_WITHOUT_CACHE: { - res.set('Cache-Control', 'private, no-cache, no-transform'); - break; - } + const cacheControlHeader = cacheControlHeaders[file.cacheControl]; + if (cacheControlHeader) { + // set the header to Cache-Control + res.set('Cache-Control', cacheControlHeader); } res.header('Content-Type', file.contentType); @@ -61,6 +62,7 @@ export const sendFile = async ( res.header('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(file.fileName)}`); } + // configure options for serving const options: SendFileOptions = { dotfiles: 'allow' }; if (!isAbsolute(file.path)) { options.root = process.cwd(); diff --git a/server/src/utils/logger.ts b/server/src/utils/logger.ts index 2fe2c618be..f2f47e0471 100644 --- a/server/src/utils/logger.ts +++ b/server/src/utils/logger.ts @@ -1,8 +1,8 @@ import { HttpException } from '@nestjs/common'; -import { ILoggingRepository } from 'src/types'; +import { LoggingRepository } from 'src/repositories/logging.repository'; import { TypeORMError } from 'typeorm'; -export const logGlobalError = (logger: ILoggingRepository, error: Error) => { +export const logGlobalError = (logger: LoggingRepository, error: Error) => { if (error instanceof HttpException) { const status = error.getStatus(); const response = error.getResponse(); diff --git a/server/src/utils/mime-types.spec.ts b/server/src/utils/mime-types.spec.ts index bf471fc1d5..6c2f92c2ee 100644 --- a/server/src/utils/mime-types.spec.ts +++ b/server/src/utils/mime-types.spec.ts @@ -22,6 +22,7 @@ describe('mimeTypes', () => { { mimetype: 'image/heif', extension: '.heif' }, { mimetype: 'image/hif', extension: '.hif' }, { mimetype: 'image/iiq', extension: '.iiq' }, + { mimetype: 'image/jp2', extension: '.jp2' }, { mimetype: 'image/jpeg', extension: '.jpe' }, { mimetype: 'image/jpeg', extension: '.jpeg' }, { mimetype: 'image/jpeg', extension: '.jpg' }, diff --git a/server/src/utils/mime-types.ts b/server/src/utils/mime-types.ts index 6e1b4f083b..37dfe8153a 100644 --- a/server/src/utils/mime-types.ts +++ b/server/src/utils/mime-types.ts @@ -43,6 +43,7 @@ const image: Record = { '.heif': ['image/heif'], '.hif': ['image/hif'], '.insp': ['image/jpeg'], + '.jp2': ['image/jp2'], '.jpe': ['image/jpeg'], '.jpeg': ['image/jpeg'], '.jpg': ['image/jpeg'], diff --git a/server/src/utils/misc.spec.ts b/server/src/utils/misc.spec.ts index 53be77dc21..3c45482938 100644 --- a/server/src/utils/misc.spec.ts +++ b/server/src/utils/misc.spec.ts @@ -1,4 +1,4 @@ -import { getKeysDeep, unsetDeep } from 'src/utils/misc'; +import { getKeysDeep, globToSqlPattern, unsetDeep } from 'src/utils/misc'; import { describe, expect, it } from 'vitest'; describe('getKeysDeep', () => { @@ -51,3 +51,19 @@ describe('unsetDeep', () => { expect(unsetDeep({ foo: 'bar', nested: { enabled: true } }, 'nested.enabled')).toEqual({ foo: 'bar' }); }); }); + +describe('globToSqlPattern', () => { + const testCases = [ + ['**/Raw/**', '%/Raw/%'], + ['**/abc/*.tif', '%/abc/%.tif'], + ['**/*.tif', '%/%.tif'], + ['**/*.jp?', '%/%.jp_'], + ['**/@eaDir/**', '%/@eaDir/%'], + ['**/._*', `%/._%`], + ['/absolute/path/**', `/absolute/path/%`], + ]; + + it.each(testCases)('should convert %s to %s', (input, expected) => { + expect(globToSqlPattern(input)).toEqual(expected); + }); +}); diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index d53f9ecf36..b0c4fd955f 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -10,10 +10,13 @@ import { ReferenceObject, SchemaObject } from '@nestjs/swagger/dist/interfaces/o import _ from 'lodash'; import { writeFileSync } from 'node:fs'; import path from 'node:path'; +import picomatch from 'picomatch'; +import parse from 'picomatch/lib/parse'; import { SystemConfig } from 'src/config'; import { CLIP_MODEL_INFO, serverVersion } from 'src/constants'; +import { extraSyncModels } from 'src/dtos/sync.dto'; import { ImmichCookie, ImmichHeader, MetadataKey } from 'src/enum'; -import { ILoggingRepository } from 'src/types'; +import { LoggingRepository } from 'src/repositories/logging.repository'; export class ImmichStartupError extends Error {} export const isStartUpError = (error: unknown): error is ImmichStartupError => error instanceof ImmichStartupError; @@ -96,7 +99,7 @@ export const isFaceImportEnabled = (metadata: SystemConfig['metadata']) => metad export const isConnectionAborted = (error: Error | any) => error.code === 'ECONNABORTED'; -export const handlePromiseError = (promise: Promise, logger: ILoggingRepository): void => { +export const handlePromiseError = (promise: Promise, logger: LoggingRepository): void => { promise.catch((error: Error | any) => logger.error(`Promise error: ${error}`, error?.stack)); }; @@ -245,6 +248,7 @@ export const useSwagger = (app: INestApplication, { write }: { write: boolean }) const options: SwaggerDocumentOptions = { operationIdFactory: (controllerKey: string, methodKey: string) => methodKey, + extraModels: extraSyncModels, }; const specification = SwaggerModule.createDocument(app, config, options); @@ -266,3 +270,35 @@ export const useSwagger = (app: INestApplication, { write }: { write: boolean }) writeFileSync(outputPath, JSON.stringify(patchOpenAPI(specification), null, 2), { encoding: 'utf8' }); } }; + +const convertTokenToSqlPattern = (token: parse.Token): string => { + switch (token.type) { + case 'slash': { + return '/'; + } + case 'text': { + return token.value; + } + case 'globstar': + case 'star': { + return '%'; + } + case 'underscore': { + return String.raw`\_`; + } + case 'qmark': { + return '_'; + } + case 'dot': { + return '.'; + } + default: { + return ''; + } + } +}; + +export const globToSqlPattern = (glob: string) => { + const tokens = picomatch.parse(glob).tokens; + return tokens.map((token) => convertTokenToSqlPattern(token)).join(''); +}; diff --git a/server/src/utils/pagination.ts b/server/src/utils/pagination.ts index 7cb31d1e04..eb4106c86a 100644 --- a/server/src/utils/pagination.ts +++ b/server/src/utils/pagination.ts @@ -10,6 +10,7 @@ export interface PaginationResult { export type Paginated = Promise>; +/** @deprecated use `this.db. ... .stream()` instead */ export async function* usePagination( pageSize: number, getNextPage: (pagination: PaginationOptions) => PaginationResult | Paginated, diff --git a/server/src/utils/preferences.ts b/server/src/utils/preferences.ts index ed9b5f2b83..14e61f1919 100644 --- a/server/src/utils/preferences.ts +++ b/server/src/utils/preferences.ts @@ -1,18 +1,13 @@ import _ from 'lodash'; import { UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; -import { UserPreferences, getDefaultPreferences } from 'src/entities/user-metadata.entity'; -import { UserEntity } from 'src/entities/user.entity'; +import { UserMetadataItem, UserPreferences, getDefaultPreferences } from 'src/entities/user-metadata.entity'; import { UserMetadataKey } from 'src/enum'; import { DeepPartial } from 'src/types'; import { getKeysDeep } from 'src/utils/misc'; -export const getPreferences = (user: UserEntity) => { - const preferences = getDefaultPreferences(user); - if (!user.metadata) { - return preferences; - } - - const item = user.metadata.find(({ key }) => key === UserMetadataKey.PREFERENCES); +export const getPreferences = (email: string, metadata: UserMetadataItem[]): UserPreferences => { + const preferences = getDefaultPreferences({ email }); + const item = metadata.find(({ key }) => key === UserMetadataKey.PREFERENCES); const partial = item?.value || {}; for (const property of getKeysDeep(partial)) { _.set(preferences, property, _.get(partial, property)); @@ -40,8 +35,7 @@ export const getPreferencesPartial = (user: { email: string }, newPreferences: U return partial; }; -export const mergePreferences = (user: UserEntity, dto: UserPreferencesUpdateDto) => { - const preferences = getPreferences(user); +export const mergePreferences = (preferences: UserPreferences, dto: UserPreferencesUpdateDto) => { for (const key of getKeysDeep(dto)) { _.set(preferences, key, _.get(dto, key)); } diff --git a/server/src/utils/sync.ts b/server/src/utils/sync.ts new file mode 100644 index 0000000000..cfb6660bdc --- /dev/null +++ b/server/src/utils/sync.ts @@ -0,0 +1,28 @@ +import { SyncItem } from 'src/dtos/sync.dto'; +import { SyncEntityType } from 'src/enum'; +import { SyncAck } from 'src/types'; + +type Impossible = { + [P in K]: never; +}; + +type Exact = U & Impossible>; + +export const fromAck = (ack: string): SyncAck => { + const [type, updateId] = ack.split('|'); + return { type: type as SyncEntityType, updateId }; +}; + +export const toAck = ({ type, updateId }: SyncAck) => [type, updateId].join('|'); + +export const mapJsonLine = (object: unknown) => JSON.stringify(object) + '\n'; + +export const serialize = ({ + type, + updateId, + data, +}: { + type: T; + updateId: string; + data: Exact; +}) => mapJsonLine({ type, data, ack: toAck({ type, updateId }) }); diff --git a/server/src/utils/tag.ts b/server/src/utils/tag.ts index 027afcf040..b095fcfd85 100644 --- a/server/src/utils/tag.ts +++ b/server/src/utils/tag.ts @@ -1,19 +1,19 @@ -import { TagEntity } from 'src/entities/tag.entity'; -import { ITagRepository } from 'src/interfaces/tag.interface'; +import { TagRepository } from 'src/repositories/tag.repository'; +import { TagItem } from 'src/types'; type UpsertRequest = { userId: string; tags: string[] }; -export const upsertTags = async (repository: ITagRepository, { userId, tags }: UpsertRequest) => { +export const upsertTags = async (repository: TagRepository, { userId, tags }: UpsertRequest) => { tags = [...new Set(tags)]; - const results: TagEntity[] = []; + const results: TagItem[] = []; for (const tag of tags) { const parts = tag.split('/').filter(Boolean); - let parent: TagEntity | undefined; + let parent: TagItem | undefined; for (const part of parts) { const value = parent ? `${parent.value}/${part}` : part; - parent = await repository.upsertValue({ userId, value, parent }); + parent = await repository.upsertValue({ userId, value, parentId: parent?.id }); } if (parent) { diff --git a/server/src/validation.ts b/server/src/validation.ts index 177e439919..29e402826d 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -12,6 +12,7 @@ import { IsArray, IsBoolean, IsDate, + IsHexColor, IsNotEmpty, IsOptional, IsString, @@ -97,6 +98,15 @@ export function Optional({ nullable, emptyToNull, ...validationOptions }: Option return applyDecorators(...decorators); } +export const ValidateHexColor = () => { + const decorators = [ + IsHexColor(), + Transform(({ value }) => (typeof value === 'string' && value[0] !== '#' ? `#${value}` : value)), + ]; + + return applyDecorators(...decorators); +}; + type UUIDOptions = { optional?: boolean; each?: boolean; nullable?: boolean }; export const ValidateUUID = (options?: UUIDOptions) => { const { optional, each, nullable } = { optional: false, each: false, nullable: false, ...options }; diff --git a/server/src/workers/api.ts b/server/src/workers/api.ts index d6dc7233d1..ddf6e50aa2 100644 --- a/server/src/workers/api.ts +++ b/server/src/workers/api.ts @@ -62,7 +62,7 @@ async function bootstrap() { app.use(app.get(ApiService).ssr(excludePaths)); const server = await (host ? app.listen(port, host) : app.listen(port)); - server.requestTimeout = 30 * 60 * 1000; + server.requestTimeout = 24 * 60 * 60 * 1000; logger.log(`Immich Server is listening on ${await app.getUrl()} [v${serverVersion}] [${environment}] `); } diff --git a/server/test/factory.ts b/server/test/factory.ts new file mode 100644 index 0000000000..520119fc3e --- /dev/null +++ b/server/test/factory.ts @@ -0,0 +1,274 @@ +import { Insertable, Kysely } from 'kysely'; +import { randomBytes } from 'node:crypto'; +import { Writable } from 'node:stream'; +import { Assets, DB, Partners, Sessions, Users } from 'src/db'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { AssetType } from 'src/enum'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { ActivityRepository } from 'src/repositories/activity.repository'; +import { AlbumRepository } from 'src/repositories/album.repository'; +import { ApiKeyRepository } from 'src/repositories/api-key.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { AuditRepository } from 'src/repositories/audit.repository'; +import { ConfigRepository } from 'src/repositories/config.repository'; +import { LibraryRepository } from 'src/repositories/library.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { MachineLearningRepository } from 'src/repositories/machine-learning.repository'; +import { MediaRepository } from 'src/repositories/media.repository'; +import { MetadataRepository } from 'src/repositories/metadata.repository'; +import { MoveRepository } from 'src/repositories/move.repository'; +import { NotificationRepository } from 'src/repositories/notification.repository'; +import { OAuthRepository } from 'src/repositories/oauth.repository'; +import { PartnerRepository } from 'src/repositories/partner.repository'; +import { PersonRepository } from 'src/repositories/person.repository'; +import { ProcessRepository } from 'src/repositories/process.repository'; +import { SearchRepository } from 'src/repositories/search.repository'; +import { ServerInfoRepository } from 'src/repositories/server-info.repository'; +import { SessionRepository } from 'src/repositories/session.repository'; +import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; +import { StackRepository } from 'src/repositories/stack.repository'; +import { StorageRepository } from 'src/repositories/storage.repository'; +import { SyncRepository } from 'src/repositories/sync.repository'; +import { TelemetryRepository } from 'src/repositories/telemetry.repository'; +import { TrashRepository } from 'src/repositories/trash.repository'; +import { UserRepository } from 'src/repositories/user.repository'; +import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; +import { ViewRepository } from 'src/repositories/view-repository'; +import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock'; +import { newUuid } from 'test/small.factory'; +import { automock } from 'test/utils'; + +class CustomWritable extends Writable { + private data = ''; + + _write(chunk: any, encoding: string, callback: () => void) { + this.data += chunk.toString(); + callback(); + } + + getResponse() { + const result = this.data; + return result + .split('\n') + .filter((x) => x.length > 0) + .map((x) => JSON.parse(x)); + } +} + +type Asset = Partial>; +type User = Partial>; +type Session = Omit, 'token'> & { token?: string }; +type Partner = Insertable; + +export class TestFactory { + private assets: Asset[] = []; + private sessions: Session[] = []; + private users: User[] = []; + private partners: Partner[] = []; + + private constructor(private context: TestContext) {} + + static create(context: TestContext) { + return new TestFactory(context); + } + + static stream() { + return new CustomWritable(); + } + + static asset(asset: Asset) { + const assetId = asset.id || newUuid(); + const defaults: Insertable = { + deviceAssetId: '', + deviceId: '', + originalFileName: '', + checksum: randomBytes(32), + type: AssetType.IMAGE, + originalPath: '/path/to/something.jpg', + ownerId: '@immich.cloud', + isVisible: true, + }; + + return { + ...defaults, + ...asset, + id: assetId, + }; + } + + static auth(auth: { user: User; session?: Session }) { + return auth as AuthDto; + } + + static user(user: User = {}) { + const userId = user.id || newUuid(); + const defaults: Insertable = { + email: `${userId}@immich.cloud`, + name: `User ${userId}`, + deletedAt: null, + }; + + return { + ...defaults, + ...user, + id: userId, + }; + } + + static session(session: Session) { + const id = session.id || newUuid(); + const defaults = { + token: randomBytes(36).toString('base64url'), + }; + + return { + ...defaults, + ...session, + id, + }; + } + + static partner(partner: Partner) { + const defaults = { + inTimeline: true, + }; + + return { + ...defaults, + ...partner, + }; + } + + withAsset(asset: Asset) { + this.assets.push(asset); + return this; + } + + withSession(session: Session) { + this.sessions.push(session); + return this; + } + + withUser(user: User = {}) { + this.users.push(user); + return this; + } + + withPartner(partner: Partner) { + this.partners.push(partner); + return this; + } + + async create() { + for (const user of this.users) { + await this.context.createUser(user); + } + + for (const partner of this.partners) { + await this.context.createPartner(partner); + } + + for (const session of this.sessions) { + await this.context.createSession(session); + } + + for (const asset of this.assets) { + await this.context.createAsset(asset); + } + + return this.context; + } +} + +export class TestContext { + access: AccessRepository; + logger: LoggingRepository; + activity: ActivityRepository; + album: AlbumRepository; + apiKey: ApiKeyRepository; + asset: AssetRepository; + audit: AuditRepository; + config: ConfigRepository; + library: LibraryRepository; + machineLearning: MachineLearningRepository; + media: MediaRepository; + metadata: MetadataRepository; + move: MoveRepository; + notification: NotificationRepository; + oauth: OAuthRepository; + partner: PartnerRepository; + person: PersonRepository; + process: ProcessRepository; + search: SearchRepository; + serverInfo: ServerInfoRepository; + session: SessionRepository; + sharedLink: SharedLinkRepository; + stack: StackRepository; + storage: StorageRepository; + sync: SyncRepository; + telemetry: TelemetryRepository; + trash: TrashRepository; + user: UserRepository; + versionHistory: VersionHistoryRepository; + view: ViewRepository; + + private constructor(public db: Kysely) { + const logger = automock(LoggingRepository, { args: [, { getEnv: () => ({}) }], strict: false }); + const config = new ConfigRepository(); + + this.access = new AccessRepository(this.db); + this.logger = logger; + this.activity = new ActivityRepository(this.db); + this.album = new AlbumRepository(this.db); + this.apiKey = new ApiKeyRepository(this.db); + this.asset = new AssetRepository(this.db); + this.audit = new AuditRepository(this.db); + this.config = config; + this.library = new LibraryRepository(this.db); + this.machineLearning = new MachineLearningRepository(logger); + this.media = new MediaRepository(logger); + this.metadata = new MetadataRepository(logger); + this.move = new MoveRepository(this.db); + this.notification = new NotificationRepository(logger); + this.oauth = new OAuthRepository(logger); + this.partner = new PartnerRepository(this.db); + this.person = new PersonRepository(this.db); + this.process = new ProcessRepository(logger); + this.search = new SearchRepository(logger, this.db); + this.serverInfo = new ServerInfoRepository(config, logger); + this.session = new SessionRepository(this.db); + this.sharedLink = new SharedLinkRepository(this.db); + this.stack = new StackRepository(this.db); + this.storage = new StorageRepository(logger); + this.sync = new SyncRepository(this.db); + this.telemetry = newTelemetryRepositoryMock() as unknown as TelemetryRepository; + this.trash = new TrashRepository(this.db); + this.user = new UserRepository(this.db); + this.versionHistory = new VersionHistoryRepository(this.db); + this.view = new ViewRepository(this.db); + } + + static from(db: Kysely) { + return new TestContext(db).getFactory(); + } + + getFactory() { + return TestFactory.create(this); + } + + createUser(user: User = {}) { + return this.user.create(TestFactory.user(user)); + } + + createPartner(partner: Partner) { + return this.partner.create(TestFactory.partner(partner)); + } + + createAsset(asset: Asset) { + return this.asset.create(TestFactory.asset(asset)); + } + + createSession(session: Session) { + return this.session.create(TestFactory.session(session)); + } +} diff --git a/server/test/fixtures/activity.stub.ts b/server/test/fixtures/activity.stub.ts deleted file mode 100644 index 9578bcd4a1..0000000000 --- a/server/test/fixtures/activity.stub.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { ActivityItem } from 'src/types'; -import { albumStub } from 'test/fixtures/album.stub'; -import { assetStub } from 'test/fixtures/asset.stub'; - -export const activityStub = { - oneComment: Object.freeze({ - id: 'activity-1', - comment: 'comment', - isLiked: false, - userId: 'admin_id', - user: { - id: 'admin_id', - name: 'admin', - email: 'admin@test.com', - profileImagePath: '', - profileChangedAt: new Date('2021-01-01'), - }, - assetId: assetStub.image.id, - albumId: albumStub.oneAsset.id, - createdAt: new Date(), - updatedAt: new Date(), - }), - liked: Object.freeze({ - id: 'activity-2', - comment: null, - isLiked: true, - userId: 'admin_id', - user: { - id: 'admin_id', - name: 'admin', - email: 'admin@test.com', - profileImagePath: '', - profileChangedAt: new Date('2021-01-01'), - }, - assetId: assetStub.image.id, - albumId: albumStub.oneAsset.id, - createdAt: new Date(), - updatedAt: new Date(), - }), -}; diff --git a/server/test/fixtures/api-key.stub.ts b/server/test/fixtures/api-key.stub.ts deleted file mode 100644 index 905bda34b4..0000000000 --- a/server/test/fixtures/api-key.stub.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { authStub } from 'test/fixtures/auth.stub'; -import { userStub } from 'test/fixtures/user.stub'; - -export const keyStub = { - authKey: Object.freeze({ - id: 'my-random-guid', - key: 'my-api-key (hashed)', - user: userStub.admin, - permissions: [], - } as any), - - admin: Object.freeze({ - id: 'my-random-guid', - name: 'My Key', - key: 'my-api-key (hashed)', - userId: authStub.admin.user.id, - user: userStub.admin, - permissions: [], - } as any), -}; diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 8f6c794790..c0902dddb3 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -3,9 +3,9 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { StackEntity } from 'src/entities/stack.entity'; import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; +import { StorageAsset } from 'src/types'; import { authStub } from 'test/fixtures/auth.stub'; import { fileStub } from 'test/fixtures/file.stub'; -import { libraryStub } from 'test/fixtures/library.stub'; import { userStub } from 'test/fixtures/user.stub'; const previewFile: AssetFileEntity = { @@ -40,6 +40,21 @@ export const stackStub = (stackId: string, assets: AssetEntity[]): StackEntity = }; export const assetStub = { + storageAsset: (asset: Partial = {}) => ({ + id: 'asset-id', + ownerId: 'user-id', + livePhotoVideoId: null, + type: AssetType.IMAGE, + isExternal: false, + checksum: Buffer.from('file hash'), + timeZone: null, + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + originalPath: '/original/path.jpg', + originalFileName: 'IMG_123.jpg', + sidecarPath: null, + fileSizeInByte: 12_345, + ...asset, + }), noResizePath: Object.freeze({ id: 'asset-id', status: AssetStatus.ACTIVE, @@ -184,6 +199,7 @@ export const assetStub = { exifImageHeight: 1000, exifImageWidth: 1000, } as ExifEntity, + stackId: 'stack-1', stack: stackStub('stack-1', [ { id: 'primary-asset-id' } as AssetEntity, { id: 'stack-child-asset-1' } as AssetEntity, @@ -210,7 +226,7 @@ export const assetStub = { encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), + localDateTime: new Date('2025-01-01T01:02:03.456Z'), isFavorite: true, isArchived: false, duration: null, @@ -295,6 +311,7 @@ export const assetStub = { isFavorite: false, isArchived: false, duration: null, + libraryId: 'library-id', isVisible: true, isExternal: false, livePhotoVideo: null, @@ -378,7 +395,6 @@ export const assetStub = { livePhotoVideo: null, livePhotoVideoId: null, libraryId: 'library-id', - library: libraryStub.externalLibrary1, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -574,7 +590,7 @@ export const assetStub = { encodedVideoPath: null, createdAt: new Date('2023-02-22T05:06:29.716Z'), updatedAt: new Date('2023-02-22T05:06:29.716Z'), - localDateTime: new Date('2023-02-22T05:06:29.716Z'), + localDateTime: new Date('2020-12-31T23:59:00.000Z'), isFavorite: false, isArchived: false, isExternal: false, @@ -733,7 +749,6 @@ export const assetStub = { livePhotoVideo: null, livePhotoVideoId: null, libraryId: 'library-id', - library: libraryStub.externalLibrary1, tags: [], sharedLinks: [], originalFileName: 'photo.jpg', diff --git a/server/test/fixtures/auth.stub.ts b/server/test/fixtures/auth.stub.ts index 2989c0cce1..f894314258 100644 --- a/server/test/fixtures/auth.stub.ts +++ b/server/test/fixtures/auth.stub.ts @@ -1,25 +1,30 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { SessionEntity } from 'src/entities/session.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; -import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; -import { UserEntity } from 'src/entities/user.entity'; + +const authUser = { + admin: { + id: 'admin_id', + name: 'admin', + email: 'admin@test.com', + isAdmin: true, + quotaSizeInBytes: null, + quotaUsageInBytes: 0, + }, + user1: { + id: 'user-id', + name: 'User 1', + email: 'immich@test.com', + isAdmin: false, + quotaSizeInBytes: null, + quotaUsageInBytes: 0, + }, +}; export const authStub = { - admin: Object.freeze({ - user: { - id: 'admin_id', - email: 'admin@test.com', - isAdmin: true, - metadata: [] as UserMetadataEntity[], - } as UserEntity, - }), + admin: Object.freeze({ user: authUser.admin }), user1: Object.freeze({ - user: { - id: 'user-id', - email: 'immich@test.com', - isAdmin: false, - metadata: [] as UserMetadataEntity[], - } as UserEntity, + user: authUser.user1, session: { id: 'token-id', } as SessionEntity, @@ -27,21 +32,18 @@ export const authStub = { user2: Object.freeze({ user: { id: 'user-2', - email: 'user2@immich.app', + name: 'User 2', + email: 'user2@immich.cloud', isAdmin: false, - metadata: [] as UserMetadataEntity[], - } as UserEntity, + quotaSizeInBytes: null, + quotaUsageInBytes: 0, + }, session: { id: 'token-id', } as SessionEntity, }), adminSharedLink: Object.freeze({ - user: { - id: 'admin_id', - email: 'admin@test.com', - isAdmin: true, - metadata: [] as UserMetadataEntity[], - } as UserEntity, + user: authUser.admin, sharedLink: { id: '123', showExif: true, @@ -51,12 +53,7 @@ export const authStub = { } as SharedLinkEntity, }), adminSharedLinkNoExif: Object.freeze({ - user: { - id: 'admin_id', - email: 'admin@test.com', - isAdmin: true, - metadata: [] as UserMetadataEntity[], - } as UserEntity, + user: authUser.admin, sharedLink: { id: '123', showExif: false, @@ -66,12 +63,7 @@ export const authStub = { } as SharedLinkEntity, }), passwordSharedLink: Object.freeze({ - user: { - id: 'admin_id', - email: 'admin@test.com', - isAdmin: true, - metadata: [] as UserMetadataEntity[], - } as UserEntity, + user: authUser.admin, sharedLink: { id: '123', allowUpload: false, diff --git a/server/test/fixtures/face.stub.ts b/server/test/fixtures/face.stub.ts index 4da4e6a0c4..74a59a85a8 100644 --- a/server/test/fixtures/face.stub.ts +++ b/server/test/fixtures/face.stub.ts @@ -20,8 +20,9 @@ export const faceStub = { imageWidth: 1024, sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId1', embedding: '[1, 2, 3, 4]' }, + deletedAt: new Date(), }), - primaryFace1: Object.freeze>({ + primaryFace1: Object.freeze({ id: 'assetFaceId2', assetId: assetStub.image.id, asset: assetStub.image, @@ -35,8 +36,9 @@ export const faceStub = { imageWidth: 1024, sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId2', embedding: '[1, 2, 3, 4]' }, + deletedAt: null, }), - mergeFace1: Object.freeze>({ + mergeFace1: Object.freeze({ id: 'assetFaceId3', assetId: assetStub.image.id, asset: assetStub.image, @@ -50,8 +52,9 @@ export const faceStub = { imageWidth: 1024, sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId3', embedding: '[1, 2, 3, 4]' }, + deletedAt: null, }), - start: Object.freeze>({ + start: Object.freeze({ id: 'assetFaceId5', assetId: assetStub.image.id, asset: assetStub.image, @@ -65,8 +68,9 @@ export const faceStub = { imageWidth: 2160, sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId5', embedding: '[1, 2, 3, 4]' }, + deletedAt: null, }), - middle: Object.freeze>({ + middle: Object.freeze({ id: 'assetFaceId6', assetId: assetStub.image.id, asset: assetStub.image, @@ -80,8 +84,9 @@ export const faceStub = { imageWidth: 400, sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId6', embedding: '[1, 2, 3, 4]' }, + deletedAt: null, }), - end: Object.freeze>({ + end: Object.freeze({ id: 'assetFaceId7', assetId: assetStub.image.id, asset: assetStub.image, @@ -95,6 +100,7 @@ export const faceStub = { imageWidth: 500, sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId7', embedding: '[1, 2, 3, 4]' }, + deletedAt: null, }), noPerson1: Object.freeze({ id: 'assetFaceId8', @@ -110,6 +116,7 @@ export const faceStub = { imageWidth: 1024, sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId8', embedding: '[1, 2, 3, 4]' }, + deletedAt: null, }), noPerson2: Object.freeze({ id: 'assetFaceId9', @@ -125,6 +132,7 @@ export const faceStub = { imageWidth: 1024, sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId9', embedding: '[1, 2, 3, 4]' }, + deletedAt: null, }), fromExif1: Object.freeze({ id: 'assetFaceId9', @@ -139,6 +147,7 @@ export const faceStub = { imageHeight: 500, imageWidth: 400, sourceType: SourceType.EXIF, + deletedAt: null, }), fromExif2: Object.freeze({ id: 'assetFaceId9', @@ -153,5 +162,6 @@ export const faceStub = { imageHeight: 1024, imageWidth: 1024, sourceType: SourceType.EXIF, + deletedAt: null, }), }; diff --git a/server/test/fixtures/library.stub.ts b/server/test/fixtures/library.stub.ts deleted file mode 100644 index bb40035dcc..0000000000 --- a/server/test/fixtures/library.stub.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { LibraryEntity } from 'src/entities/library.entity'; -import { userStub } from 'test/fixtures/user.stub'; - -export const libraryStub = { - externalLibrary1: Object.freeze({ - id: 'library-id', - name: 'test_library', - assets: [], - owner: userStub.admin, - ownerId: 'admin_id', - importPaths: [], - createdAt: new Date('2023-01-01'), - updatedAt: new Date('2023-01-01'), - refreshedAt: null, - exclusionPatterns: [], - }), - externalLibrary2: Object.freeze({ - id: 'library-id2', - name: 'test_library2', - assets: [], - owner: userStub.admin, - ownerId: 'admin_id', - importPaths: [], - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2022-01-01'), - refreshedAt: null, - exclusionPatterns: [], - }), - externalLibraryWithImportPaths1: Object.freeze({ - id: 'library-id-with-paths1', - name: 'library-with-import-paths1', - assets: [], - owner: userStub.admin, - ownerId: 'admin_id', - importPaths: ['/foo', '/bar'], - createdAt: new Date('2023-01-01'), - updatedAt: new Date('2023-01-01'), - refreshedAt: null, - exclusionPatterns: [], - }), - externalLibraryWithImportPaths2: Object.freeze({ - id: 'library-id-with-paths2', - name: 'library-with-import-paths2', - assets: [], - owner: userStub.admin, - ownerId: 'admin_id', - importPaths: ['/xyz', '/asdf'], - createdAt: new Date('2023-01-01'), - updatedAt: new Date('2023-01-01'), - refreshedAt: null, - exclusionPatterns: [], - }), - patternPath: Object.freeze({ - id: 'library-id1337', - name: 'importpath-exclusion-library1', - assets: [], - owner: userStub.admin, - ownerId: 'user-id', - importPaths: ['/xyz', '/asdf'], - createdAt: new Date('2023-01-01'), - updatedAt: new Date('2023-01-01'), - refreshedAt: null, - exclusionPatterns: ['**/dir1/**'], - }), - hasImmichPaths: Object.freeze({ - id: 'library-id1337', - name: 'importpath-exclusion-library1', - assets: [], - owner: userStub.admin, - ownerId: 'user-id', - importPaths: ['upload/thumbs', 'xyz', 'upload/library'], - createdAt: new Date('2023-01-01'), - updatedAt: new Date('2023-01-01'), - refreshedAt: null, - exclusionPatterns: ['**/dir1/**'], - }), -}; diff --git a/server/test/fixtures/memory.stub.ts b/server/test/fixtures/memory.stub.ts deleted file mode 100644 index 5b3d5635c4..0000000000 --- a/server/test/fixtures/memory.stub.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { MemoryType } from 'src/enum'; -import { assetStub } from 'test/fixtures/asset.stub'; -import { userStub } from 'test/fixtures/user.stub'; - -export const memoryStub = { - empty: { - id: 'memoryEmpty', - createdAt: new Date(), - updatedAt: new Date(), - memoryAt: new Date(2024), - ownerId: userStub.admin.id, - owner: userStub.admin, - type: MemoryType.ON_THIS_DAY, - data: { year: 2024 }, - isSaved: false, - assets: [], - deletedAt: null, - seenAt: null, - } as unknown as any, - memory1: { - id: 'memory1', - createdAt: new Date(), - updatedAt: new Date(), - memoryAt: new Date(2024), - ownerId: userStub.admin.id, - owner: userStub.admin, - type: MemoryType.ON_THIS_DAY, - data: { year: 2024 }, - isSaved: false, - assets: [assetStub.image1], - deletedAt: null, - seenAt: null, - } as unknown as any, -}; diff --git a/server/test/fixtures/person.stub.ts b/server/test/fixtures/person.stub.ts index 544894b31e..ecd5b0dbea 100644 --- a/server/test/fixtures/person.stub.ts +++ b/server/test/fixtures/person.stub.ts @@ -15,6 +15,7 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: false, + isFavorite: false, }), hidden: Object.freeze({ id: 'person-1', @@ -29,6 +30,7 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: true, + isFavorite: false, }), withName: Object.freeze({ id: 'person-1', @@ -43,6 +45,7 @@ export const personStub = { faceAssetId: 'assetFaceId', faceAsset: null, isHidden: false, + isFavorite: false, }), withBirthDate: Object.freeze({ id: 'person-1', @@ -57,6 +60,7 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: false, + isFavorite: false, }), noThumbnail: Object.freeze({ id: 'person-1', @@ -71,6 +75,7 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: false, + isFavorite: false, }), newThumbnail: Object.freeze({ id: 'person-1', @@ -85,6 +90,7 @@ export const personStub = { faceAssetId: 'asset-id', faceAsset: null, isHidden: false, + isFavorite: false, }), primaryPerson: Object.freeze({ id: 'person-1', @@ -99,6 +105,7 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: false, + isFavorite: false, }), mergePerson: Object.freeze({ id: 'person-2', @@ -113,6 +120,7 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: false, + isFavorite: false, }), randomPerson: Object.freeze({ id: 'person-3', @@ -127,5 +135,21 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: false, + isFavorite: false, + }), + isFavorite: Object.freeze({ + id: 'person-4', + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + ownerId: userStub.admin.id, + owner: userStub.admin, + name: 'Person 1', + birthDate: null, + thumbnailPath: '/path/to/thumbnail.jpg', + faces: [], + faceAssetId: 'assetFaceId', + faceAsset: null, + isHidden: false, + isFavorite: true, }), }; diff --git a/server/test/fixtures/session.stub.ts b/server/test/fixtures/session.stub.ts index cdf499c8d1..af06237473 100644 --- a/server/test/fixtures/session.stub.ts +++ b/server/test/fixtures/session.stub.ts @@ -11,6 +11,7 @@ export const sessionStub = { updatedAt: new Date(), deviceType: '', deviceOS: '', + updateId: 'uuid-v7', }), inactive: Object.freeze({ id: 'not_active', @@ -21,5 +22,6 @@ export const sessionStub = { updatedAt: new Date('2021-01-01'), deviceType: 'Mobile', deviceOS: 'Android', + updateId: 'uuid-v7', }), }; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index a8b8e02d74..6ee31c0dea 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -311,7 +311,7 @@ export const sharedLinkResponseStub = { allowUpload: false, allowDownload: false, showMetadata: false, - album: { ...albumResponse, startDate: assetResponse.fileCreatedAt, endDate: assetResponse.fileCreatedAt }, + album: { ...albumResponse, startDate: assetResponse.localDateTime, endDate: assetResponse.localDateTime }, assets: [{ ...assetResponseWithoutMetadata, exifInfo: undefined }], }), }; diff --git a/server/test/fixtures/tag.stub.ts b/server/test/fixtures/tag.stub.ts index b245bfe9e5..1a19c2a002 100644 --- a/server/test/fixtures/tag.stub.ts +++ b/server/test/fixtures/tag.stub.ts @@ -1,49 +1,51 @@ import { TagResponseDto } from 'src/dtos/tag.dto'; -import { TagEntity } from 'src/entities/tag.entity'; -import { userStub } from 'test/fixtures/user.stub'; +import { TagItem } from 'src/types'; -const parent = Object.freeze({ +const parent = Object.freeze({ id: 'tag-parent', createdAt: new Date('2021-01-01T00:00:00Z'), updatedAt: new Date('2021-01-01T00:00:00Z'), value: 'Parent', color: null, - userId: userStub.admin.id, - user: userStub.admin, + parentId: null, }); -const child = Object.freeze({ +const child = Object.freeze({ id: 'tag-child', createdAt: new Date('2021-01-01T00:00:00Z'), updatedAt: new Date('2021-01-01T00:00:00Z'), value: 'Parent/Child', color: null, - parent, - userId: userStub.admin.id, - user: userStub.admin, + parentId: parent.id, }); +const tag = { + id: 'tag-1', + createdAt: new Date('2021-01-01T00:00:00Z'), + updatedAt: new Date('2021-01-01T00:00:00Z'), + value: 'Tag1', + color: null, + parentId: null, +}; + +const color = { + id: 'tag-1', + createdAt: new Date('2021-01-01T00:00:00Z'), + updatedAt: new Date('2021-01-01T00:00:00Z'), + value: 'Tag1', + color: '#000000', + parentId: null, +}; + +const upsert = { userId: 'tag-user', updateId: 'uuid-v7' }; + export const tagStub = { - tag1: Object.freeze({ - id: 'tag-1', - createdAt: new Date('2021-01-01T00:00:00Z'), - updatedAt: new Date('2021-01-01T00:00:00Z'), - value: 'Tag1', - color: null, - userId: userStub.admin.id, - user: userStub.admin, - }), - parent, - child, - color1: Object.freeze({ - id: 'tag-1', - createdAt: new Date('2021-01-01T00:00:00Z'), - updatedAt: new Date('2021-01-01T00:00:00Z'), - value: 'Tag1', - color: '#000000', - userId: userStub.admin.id, - user: userStub.admin, - }), + tag, + tagCreate: { ...tag, ...upsert }, + color, + colorCreate: { ...color, ...upsert }, + parentUpsert: { ...parent, ...upsert }, + childUpsert: { ...child, ...upsert }, }; export const tagResponseStub = { diff --git a/server/test/fixtures/user.stub.ts b/server/test/fixtures/user.stub.ts index 9553b5344a..9153cfa8f2 100644 --- a/server/test/fixtures/user.stub.ts +++ b/server/test/fixtures/user.stub.ts @@ -1,10 +1,12 @@ import { UserEntity } from 'src/entities/user.entity'; -import { UserAvatarColor, UserMetadataKey } from 'src/enum'; +import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; import { authStub } from 'test/fixtures/auth.stub'; export const userStub = { admin: Object.freeze({ ...authStub.admin.user, + status: UserStatus.ACTIVE, + profileChangedAt: new Date('2021-01-01'), password: 'admin_password', name: 'admin_name', id: 'admin_id', @@ -23,6 +25,8 @@ export const userStub = { }), user1: Object.freeze({ ...authStub.user1.user, + status: UserStatus.ACTIVE, + profileChangedAt: new Date('2021-01-01'), password: 'immich_password', name: 'immich_name', storageLabel: null, @@ -36,7 +40,6 @@ export const userStub = { assets: [], metadata: [ { - user: authStub.user1.user, userId: authStub.user1.user.id, key: UserMetadataKey.PREFERENCES, value: { avatar: { color: UserAvatarColor.PRIMARY } }, @@ -47,6 +50,9 @@ export const userStub = { }), user2: Object.freeze({ ...authStub.user2.user, + status: UserStatus.ACTIVE, + profileChangedAt: new Date('2021-01-01'), + metadata: [], password: 'immich_password', name: 'immich_name', storageLabel: null, @@ -63,6 +69,9 @@ export const userStub = { }), storageLabel: Object.freeze({ ...authStub.user1.user, + status: UserStatus.ACTIVE, + profileChangedAt: new Date('2021-01-01'), + metadata: [], password: 'immich_password', name: 'immich_name', storageLabel: 'label-1', @@ -79,6 +88,9 @@ export const userStub = { }), profilePath: Object.freeze({ ...authStub.user1.user, + status: UserStatus.ACTIVE, + profileChangedAt: new Date('2021-01-01'), + metadata: [], password: 'immich_password', name: 'immich_name', storageLabel: 'label-1', diff --git a/server/test/global-setup.js b/server/test/global-setup.js deleted file mode 100644 index 6e1fbf41d0..0000000000 --- a/server/test/global-setup.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = async () => { - process.env.TZ = 'UTC'; -}; diff --git a/server/test/medium/globalSetup.ts b/server/test/medium/globalSetup.ts new file mode 100644 index 0000000000..3c25142073 --- /dev/null +++ b/server/test/medium/globalSetup.ts @@ -0,0 +1,60 @@ +import { GenericContainer, Wait } from 'testcontainers'; +import { DataSource } from 'typeorm'; + +const globalSetup = async () => { + const postgres = await new GenericContainer('tensorchord/pgvecto-rs:pg14-v0.2.0') + .withExposedPorts(5432) + .withEnvironment({ + POSTGRES_PASSWORD: 'postgres', + POSTGRES_USER: 'postgres', + POSTGRES_DB: 'immich', + }) + .withCommand([ + 'postgres', + '-c', + 'shared_preload_libraries=vectors.so', + '-c', + 'search_path="$$user", public, vectors', + '-c', + 'max_wal_size=2GB', + '-c', + 'shared_buffers=512MB', + '-c', + 'fsync=off', + '-c', + 'full_page_writes=off', + '-c', + 'synchronous_commit=off', + ]) + .withWaitStrategy(Wait.forAll([Wait.forLogMessage('database system is ready to accept connections', 2)])) + .start(); + + const postgresPort = postgres.getMappedPort(5432); + const postgresUrl = `postgres://postgres:postgres@localhost:${postgresPort}/immich`; + process.env.IMMICH_TEST_POSTGRES_URL = postgresUrl; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + const modules = import.meta.glob('/src/migrations/*.ts', { eager: true }); + + const config = { + type: 'postgres' as const, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + migrations: Object.values(modules).map((module) => Object.values(module)[0]), + migrationsRun: false, + synchronize: false, + connectTimeoutMS: 10_000, // 10 seconds + parseInt8: true, + url: postgresUrl, + }; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + const dataSource = new DataSource(config); + await dataSource.initialize(); + await dataSource.runMigrations(); + await dataSource.destroy(); +}; + +export default globalSetup; diff --git a/server/test/medium/specs/audit.database.spec.ts b/server/test/medium/specs/audit.database.spec.ts new file mode 100644 index 0000000000..5332193e4c --- /dev/null +++ b/server/test/medium/specs/audit.database.spec.ts @@ -0,0 +1,74 @@ +import { TestContext, TestFactory } from 'test/factory'; +import { getKyselyDB } from 'test/utils'; + +describe('audit', () => { + let context: TestContext; + + beforeAll(async () => { + const db = await getKyselyDB(); + context = await TestContext.from(db).create(); + }); + + describe('partners_audit', () => { + it('should not cascade user deletes to partners_audit', async () => { + const user1 = TestFactory.user(); + const user2 = TestFactory.user(); + + await context + .getFactory() + .withUser(user1) + .withUser(user2) + .withPartner({ sharedById: user1.id, sharedWithId: user2.id }) + .create(); + + await context.user.delete(user1, true); + + await expect( + context.db.selectFrom('partners_audit').select(['id']).where('sharedById', '=', user1.id).execute(), + ).resolves.toHaveLength(0); + }); + }); + + describe('assets_audit', () => { + it('should not cascade user deletes to assets_audit', async () => { + const user = TestFactory.user(); + const asset = TestFactory.asset({ ownerId: user.id }); + + await context.getFactory().withUser(user).withAsset(asset).create(); + + await context.user.delete(user, true); + + await expect( + context.db.selectFrom('assets_audit').select(['id']).where('assetId', '=', asset.id).execute(), + ).resolves.toHaveLength(0); + }); + }); + + describe('exif', () => { + it('should automatically set updatedAt and updateId when the row is updated', async () => { + const user = TestFactory.user(); + const asset = TestFactory.asset({ ownerId: user.id }); + const exif = { assetId: asset.id, make: 'Canon' }; + + await context.getFactory().withUser(user).withAsset(asset).create(); + await context.asset.upsertExif(exif); + + const before = await context.db + .selectFrom('exif') + .select(['updatedAt', 'updateId']) + .where('assetId', '=', asset.id) + .executeTakeFirstOrThrow(); + + await context.asset.upsertExif({ assetId: asset.id, make: 'Canon 2' }); + + const after = await context.db + .selectFrom('exif') + .select(['updatedAt', 'updateId']) + .where('assetId', '=', asset.id) + .executeTakeFirstOrThrow(); + + expect(before.updateId).not.toEqual(after.updateId); + expect(before.updatedAt).not.toEqual(after.updatedAt); + }); + }); +}); diff --git a/server/test/medium/metadata.service.spec.ts b/server/test/medium/specs/metadata.service.spec.ts similarity index 76% rename from server/test/medium/metadata.service.spec.ts rename to server/test/medium/specs/metadata.service.spec.ts index 1750584018..28f2c9f64f 100644 --- a/server/test/medium/metadata.service.spec.ts +++ b/server/test/medium/specs/metadata.service.spec.ts @@ -3,18 +3,13 @@ import { writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { AssetEntity } from 'src/entities/asset.entity'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MetadataService } from 'src/services/metadata.service'; -import { ILoggingRepository } from 'src/types'; -import { newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newRandomImage, newTestService } from 'test/utils'; -import { Mocked } from 'vitest'; +import { automock, newRandomImage, newTestService, ServiceMocks } from 'test/utils'; const metadataRepository = new MetadataRepository( - newLoggingRepositoryMock() as ILoggingRepository as LoggingRepository, + automock(LoggingRepository, { args: [, { getEnv: () => ({}) }], strict: false }), ); const createTestFile = async (exifData: Record) => { @@ -38,14 +33,12 @@ type TimeZoneTest = { describe(MetadataService.name, () => { let sut: MetadataService; - - let assetMock: Mocked; - let storageMock: Mocked; + let mocks: ServiceMocks; beforeEach(() => { - ({ sut, assetMock, storageMock } = newTestService(MetadataService, { metadataRepository })); + ({ sut, mocks } = newTestService(MetadataService, { metadata: metadataRepository })); - storageMock.stat.mockResolvedValue({ size: 123_456 } as Stats); + mocks.storage.stat.mockResolvedValue({ size: 123_456, ctime: new Date(), mtime: new Date() } as Stats); delete process.env.TZ; }); @@ -60,6 +53,8 @@ describe(MetadataService.name, () => { description: 'should handle no time zone information', exifData: { DateTimeOriginal: '2022:01:01 00:00:00', + FileCreateDate: '2022:01:01 00:00:00', + FileModifyDate: '2022:01:01 00:00:00', }, expected: { localDateTime: '2022-01-01T00:00:00.000Z', @@ -72,6 +67,8 @@ describe(MetadataService.name, () => { serverTimeZone: 'America/Los_Angeles', exifData: { DateTimeOriginal: '2022:01:01 00:00:00', + FileCreateDate: '2022:01:01 00:00:00', + FileModifyDate: '2022:01:01 00:00:00', }, expected: { localDateTime: '2022-01-01T00:00:00.000Z', @@ -84,6 +81,8 @@ describe(MetadataService.name, () => { serverTimeZone: 'Europe/Brussels', exifData: { DateTimeOriginal: '2022:01:01 00:00:00', + FileCreateDate: '2022:01:01 00:00:00', + FileModifyDate: '2022:01:01 00:00:00', }, expected: { localDateTime: '2022-01-01T00:00:00.000Z', @@ -96,6 +95,8 @@ describe(MetadataService.name, () => { serverTimeZone: 'Europe/Brussels', exifData: { DateTimeOriginal: '2022:06:01 00:00:00', + FileCreateDate: '2022:06:01 00:00:00', + FileModifyDate: '2022:06:01 00:00:00', }, expected: { localDateTime: '2022-06-01T00:00:00.000Z', @@ -107,6 +108,8 @@ describe(MetadataService.name, () => { description: 'should handle a +13:00 time zone', exifData: { DateTimeOriginal: '2022:01:01 00:00:00+13:00', + FileCreateDate: '2022:01:01 00:00:00+13:00', + FileModifyDate: '2022:01:01 00:00:00+13:00', }, expected: { localDateTime: '2022-01-01T00:00:00.000Z', @@ -120,18 +123,18 @@ describe(MetadataService.name, () => { process.env.TZ = serverTimeZone ?? undefined; const { filePath } = await createTestFile(exifData); - assetMock.getByIds.mockResolvedValue([{ id: 'asset-1', originalPath: filePath } as AssetEntity]); + mocks.asset.getByIds.mockResolvedValue([{ id: 'asset-1', originalPath: filePath } as AssetEntity]); await sut.handleMetadataExtraction({ id: 'asset-1' }); - expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ dateTimeOriginal: new Date(expected.dateTimeOriginal), timeZone: expected.timeZone, }), ); - expect(assetMock.update).toHaveBeenCalledWith( + expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ localDateTime: new Date(expected.localDateTime), }), diff --git a/server/test/medium/specs/sync.service.spec.ts b/server/test/medium/specs/sync.service.spec.ts new file mode 100644 index 0000000000..574ddde93c --- /dev/null +++ b/server/test/medium/specs/sync.service.spec.ts @@ -0,0 +1,800 @@ +import { AuthDto } from 'src/dtos/auth.dto'; +import { SyncEntityType, SyncRequestType } from 'src/enum'; +import { SYNC_TYPES_ORDER, SyncService } from 'src/services/sync.service'; +import { TestContext, TestFactory } from 'test/factory'; +import { getKyselyDB, newTestService } from 'test/utils'; + +const setup = async () => { + const user = TestFactory.user(); + const session = TestFactory.session({ userId: user.id }); + const auth = TestFactory.auth({ session, user }); + + const db = await getKyselyDB(); + + const context = await TestContext.from(db).withUser(user).withSession(session).create(); + + const { sut } = newTestService(SyncService, context); + + const testSync = async (auth: AuthDto, types: SyncRequestType[]) => { + const stream = TestFactory.stream(); + // Wait for 1ms to ensure all updates are available + await new Promise((resolve) => setTimeout(resolve, 1)); + await sut.stream(auth, stream, { types }); + + return stream.getResponse(); + }; + + return { + auth, + context, + sut, + testSync, + }; +}; + +describe(SyncService.name, () => { + it('should have all the types in the ordering variable', () => { + for (const key in SyncRequestType) { + expect(SYNC_TYPES_ORDER).includes(key); + } + + expect(SYNC_TYPES_ORDER.length).toBe(Object.keys(SyncRequestType).length); + }); + + describe.concurrent(SyncEntityType.UserV1, () => { + it('should detect and sync the first user', async () => { + const { context, auth, sut, testSync } = await setup(); + + const user = await context.user.get(auth.user.id, { withDeleted: false }); + if (!user) { + expect.fail('First user should exist'); + } + + const initialSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); + expect(initialSyncResponse).toHaveLength(1); + expect(initialSyncResponse).toEqual([ + { + ack: expect.any(String), + data: { + deletedAt: user.deletedAt, + email: user.email, + id: user.id, + name: user.name, + }, + type: 'UserV1', + }, + ]); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + const ackSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should detect and sync a soft deleted user', async () => { + const { auth, context, sut, testSync } = await setup(); + + const deletedAt = new Date().toISOString(); + const deleted = await context.createUser({ deletedAt }); + + const response = await testSync(auth, [SyncRequestType.UsersV1]); + + expect(response).toHaveLength(2); + expect(response).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + deletedAt: null, + email: auth.user.email, + id: auth.user.id, + name: auth.user.name, + }, + type: 'UserV1', + }, + { + ack: expect.any(String), + data: { + deletedAt, + email: deleted.email, + id: deleted.id, + name: deleted.name, + }, + type: 'UserV1', + }, + ]), + ); + + const acks = [response[1].ack]; + await sut.setAcks(auth, { acks }); + const ackSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should detect and sync a deleted user', async () => { + const { auth, context, sut, testSync } = await setup(); + + const user = await context.createUser(); + await context.user.delete({ id: user.id }, true); + + const response = await testSync(auth, [SyncRequestType.UsersV1]); + + expect(response).toHaveLength(2); + expect(response).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + userId: user.id, + }, + type: 'UserDeleteV1', + }, + { + ack: expect.any(String), + data: { + deletedAt: null, + email: auth.user.email, + id: auth.user.id, + name: auth.user.name, + }, + type: 'UserV1', + }, + ]), + ); + + const acks = response.map(({ ack }) => ack); + await sut.setAcks(auth, { acks }); + const ackSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should sync a user and then an update to that same user', async () => { + const { auth, context, sut, testSync } = await setup(); + + const initialSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); + + expect(initialSyncResponse).toHaveLength(1); + expect(initialSyncResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + deletedAt: null, + email: auth.user.email, + id: auth.user.id, + name: auth.user.name, + }, + type: 'UserV1', + }, + ]), + ); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + const updated = await context.user.update(auth.user.id, { name: 'new name' }); + + const updatedSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); + + expect(updatedSyncResponse).toHaveLength(1); + expect(updatedSyncResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + deletedAt: null, + email: auth.user.email, + id: auth.user.id, + name: updated.name, + }, + type: 'UserV1', + }, + ]), + ); + }); + }); + + describe.concurrent(SyncEntityType.PartnerV1, () => { + it('should detect and sync the first partner', async () => { + const { auth, context, sut, testSync } = await setup(); + + const user1 = auth.user; + const user2 = await context.createUser(); + + const partner = await context.createPartner({ sharedById: user2.id, sharedWithId: user1.id }); + + const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(initialSyncResponse).toHaveLength(1); + expect(initialSyncResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + inTimeline: partner.inTimeline, + sharedById: partner.sharedById, + sharedWithId: partner.sharedWithId, + }, + type: 'PartnerV1', + }, + ]), + ); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should detect and sync a deleted partner', async () => { + const { auth, context, sut, testSync } = await setup(); + + const user1 = auth.user; + const user2 = await context.createUser(); + + const partner = await context.createPartner({ sharedById: user2.id, sharedWithId: user1.id }); + await context.partner.remove(partner); + + const response = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(response).toHaveLength(1); + expect(response).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + sharedById: partner.sharedById, + sharedWithId: partner.sharedWithId, + }, + type: 'PartnerDeleteV1', + }, + ]), + ); + + const acks = response.map(({ ack }) => ack); + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should detect and sync a partner share both to and from another user', async () => { + const { auth, context, sut, testSync } = await setup(); + + const user1 = auth.user; + const user2 = await context.createUser(); + + const partner1 = await context.createPartner({ sharedById: user2.id, sharedWithId: user1.id }); + const partner2 = await context.createPartner({ sharedById: user1.id, sharedWithId: user2.id }); + + const response = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(response).toHaveLength(2); + expect(response).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + inTimeline: partner1.inTimeline, + sharedById: partner1.sharedById, + sharedWithId: partner1.sharedWithId, + }, + type: 'PartnerV1', + }, + { + ack: expect.any(String), + data: { + inTimeline: partner2.inTimeline, + sharedById: partner2.sharedById, + sharedWithId: partner2.sharedWithId, + }, + type: 'PartnerV1', + }, + ]), + ); + + await sut.setAcks(auth, { acks: [response[1].ack] }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should sync a partner and then an update to that same partner', async () => { + const { auth, context, sut, testSync } = await setup(); + + const user1 = auth.user; + const user2 = await context.createUser(); + + const partner = await context.createPartner({ sharedById: user2.id, sharedWithId: user1.id }); + + const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(initialSyncResponse).toHaveLength(1); + expect(initialSyncResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + inTimeline: partner.inTimeline, + sharedById: partner.sharedById, + sharedWithId: partner.sharedWithId, + }, + type: 'PartnerV1', + }, + ]), + ); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + const updated = await context.partner.update( + { sharedById: partner.sharedById, sharedWithId: partner.sharedWithId }, + { inTimeline: true }, + ); + + const updatedSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); + + expect(updatedSyncResponse).toHaveLength(1); + expect(updatedSyncResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + inTimeline: updated.inTimeline, + sharedById: updated.sharedById, + sharedWithId: updated.sharedWithId, + }, + type: 'PartnerV1', + }, + ]), + ); + }); + + it('should not sync a partner or partner delete for an unrelated user', async () => { + const { auth, context, testSync } = await setup(); + + const user2 = await context.createUser(); + const user3 = await context.createUser(); + + await context.createPartner({ sharedById: user2.id, sharedWithId: user3.id }); + + expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0); + + await context.partner.remove({ sharedById: user2.id, sharedWithId: user3.id }); + + expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0); + }); + + it('should not sync a partner delete after a user is deleted', async () => { + const { auth, context, testSync } = await setup(); + + const user2 = await context.createUser(); + await context.createPartner({ sharedById: user2.id, sharedWithId: auth.user.id }); + await context.user.delete({ id: user2.id }, true); + + expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0); + }); + }); + + describe.concurrent(SyncEntityType.AssetV1, () => { + it('should detect and sync the first asset', async () => { + const { auth, context, sut, testSync } = await setup(); + + const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA='; + const thumbhash = '2225vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA='; + const date = new Date().toISOString(); + + const asset = TestFactory.asset({ + ownerId: auth.user.id, + checksum: Buffer.from(checksum, 'base64'), + thumbhash: Buffer.from(thumbhash, 'base64'), + fileCreatedAt: date, + fileModifiedAt: date, + deletedAt: null, + }); + await context.createAsset(asset); + + const initialSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]); + + expect(initialSyncResponse).toHaveLength(1); + expect(initialSyncResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + id: asset.id, + ownerId: asset.ownerId, + thumbhash, + checksum, + deletedAt: null, + fileCreatedAt: date, + fileModifiedAt: date, + isFavorite: false, + isVisible: true, + localDateTime: null, + type: asset.type, + }, + type: 'AssetV1', + }, + ]), + ); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should detect and sync a deleted asset', async () => { + const { auth, context, sut, testSync } = await setup(); + + const asset = TestFactory.asset({ ownerId: auth.user.id }); + await context.createAsset(asset); + await context.asset.remove(asset); + + const response = await testSync(auth, [SyncRequestType.AssetsV1]); + + expect(response).toHaveLength(1); + expect(response).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + assetId: asset.id, + }, + type: 'AssetDeleteV1', + }, + ]), + ); + + const acks = response.map(({ ack }) => ack); + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should not sync an asset or asset delete for an unrelated user', async () => { + const { auth, context, testSync } = await setup(); + + const user2 = await context.createUser(); + const session = TestFactory.session({ userId: user2.id }); + const auth2 = TestFactory.auth({ session, user: user2 }); + + const asset = TestFactory.asset({ ownerId: user2.id }); + await context.createAsset(asset); + + expect(await testSync(auth2, [SyncRequestType.AssetsV1])).toHaveLength(1); + expect(await testSync(auth, [SyncRequestType.AssetsV1])).toHaveLength(0); + + await context.asset.remove(asset); + expect(await testSync(auth2, [SyncRequestType.AssetsV1])).toHaveLength(1); + expect(await testSync(auth, [SyncRequestType.AssetsV1])).toHaveLength(0); + }); + }); + + describe.concurrent(SyncRequestType.PartnerAssetsV1, () => { + it('should detect and sync the first partner asset', async () => { + const { auth, context, sut, testSync } = await setup(); + + const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA='; + const thumbhash = '2225vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA='; + const date = new Date().toISOString(); + + const user2 = await context.createUser(); + + const asset = TestFactory.asset({ + ownerId: user2.id, + checksum: Buffer.from(checksum, 'base64'), + thumbhash: Buffer.from(thumbhash, 'base64'), + fileCreatedAt: date, + fileModifiedAt: date, + deletedAt: null, + }); + await context.createAsset(asset); + await context.partner.create({ sharedById: user2.id, sharedWithId: auth.user.id }); + + const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); + + expect(initialSyncResponse).toHaveLength(1); + expect(initialSyncResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + id: asset.id, + ownerId: asset.ownerId, + thumbhash, + checksum, + deletedAt: null, + fileCreatedAt: date, + fileModifiedAt: date, + isFavorite: false, + isVisible: true, + localDateTime: null, + type: asset.type, + }, + type: SyncEntityType.PartnerAssetV1, + }, + ]), + ); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should detect and sync a deleted partner asset', async () => { + const { auth, context, sut, testSync } = await setup(); + + const user2 = await context.createUser(); + const asset = TestFactory.asset({ ownerId: user2.id }); + await context.createAsset(asset); + await context.partner.create({ sharedById: user2.id, sharedWithId: auth.user.id }); + await context.asset.remove(asset); + + const response = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); + + expect(response).toHaveLength(1); + expect(response).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + assetId: asset.id, + }, + type: SyncEntityType.PartnerAssetDeleteV1, + }, + ]), + ); + + const acks = response.map(({ ack }) => ack); + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should not sync a deleted partner asset due to a user delete', async () => { + const { auth, context, testSync } = await setup(); + + const user2 = await context.createUser(); + await context.partner.create({ sharedById: user2.id, sharedWithId: auth.user.id }); + await context.createAsset({ ownerId: user2.id }); + await context.user.delete({ id: user2.id }, true); + + const response = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); + + expect(response).toHaveLength(0); + }); + + it('should not sync a deleted partner asset due to a partner delete (unshare)', async () => { + const { auth, context, testSync } = await setup(); + + const user2 = await context.createUser(); + await context.createAsset({ ownerId: user2.id }); + const partner = { sharedById: user2.id, sharedWithId: auth.user.id }; + await context.partner.create(partner); + + await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(1); + + await context.partner.remove(partner); + + await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0); + }); + + it('should not sync an asset or asset delete for own user', async () => { + const { auth, context, testSync } = await setup(); + + const user2 = await context.createUser(); + const asset = await context.createAsset({ ownerId: auth.user.id }); + const partner = { sharedById: user2.id, sharedWithId: auth.user.id }; + await context.partner.create(partner); + + await expect(testSync(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1); + await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0); + + await context.asset.remove(asset); + + await expect(testSync(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1); + await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0); + }); + + it('should not sync an asset or asset delete for unrelated user', async () => { + const { auth, context, testSync } = await setup(); + + const user2 = await context.createUser(); + const session = TestFactory.session({ userId: user2.id }); + const auth2 = TestFactory.auth({ session, user: user2 }); + const asset = await context.createAsset({ ownerId: user2.id }); + + await expect(testSync(auth2, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1); + await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0); + + await context.asset.remove(asset); + + await expect(testSync(auth2, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1); + await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0); + }); + }); + + describe.concurrent(SyncRequestType.AssetExifsV1, () => { + it('should detect and sync the first asset exif', async () => { + const { auth, context, sut, testSync } = await setup(); + + const asset = TestFactory.asset({ ownerId: auth.user.id }); + const exif = { assetId: asset.id, make: 'Canon' }; + + await context.createAsset(asset); + await context.asset.upsertExif(exif); + + const initialSyncResponse = await testSync(auth, [SyncRequestType.AssetExifsV1]); + + expect(initialSyncResponse).toHaveLength(1); + expect(initialSyncResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + assetId: asset.id, + city: null, + country: null, + dateTimeOriginal: null, + description: '', + exifImageHeight: null, + exifImageWidth: null, + exposureTime: null, + fNumber: null, + fileSizeInByte: null, + focalLength: null, + fps: null, + iso: null, + latitude: null, + lensModel: null, + longitude: null, + make: 'Canon', + model: null, + modifyDate: null, + orientation: null, + profileDescription: null, + projectionType: null, + rating: null, + state: null, + timeZone: null, + }, + type: SyncEntityType.AssetExifV1, + }, + ]), + ); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetExifsV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should only sync asset exif for own user', async () => { + const { auth, context, testSync } = await setup(); + + const user2 = await context.createUser(); + const session = TestFactory.session({ userId: user2.id }); + const auth2 = TestFactory.auth({ session, user: user2 }); + + await context.partner.create({ sharedById: user2.id, sharedWithId: auth.user.id }); + const asset = TestFactory.asset({ ownerId: user2.id }); + const exif = { assetId: asset.id, make: 'Canon' }; + + await context.createAsset(asset); + await context.asset.upsertExif(exif); + + await expect(testSync(auth2, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1); + await expect(testSync(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(0); + }); + }); + + describe.concurrent(SyncRequestType.PartnerAssetExifsV1, () => { + it('should detect and sync the first partner asset exif', async () => { + const { auth, context, sut, testSync } = await setup(); + + const user2 = await context.createUser(); + await context.partner.create({ sharedById: user2.id, sharedWithId: auth.user.id }); + const asset = TestFactory.asset({ ownerId: user2.id }); + await context.createAsset(asset); + const exif = { assetId: asset.id, make: 'Canon' }; + await context.asset.upsertExif(exif); + + const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]); + + expect(initialSyncResponse).toHaveLength(1); + expect(initialSyncResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + assetId: asset.id, + city: null, + country: null, + dateTimeOriginal: null, + description: '', + exifImageHeight: null, + exifImageWidth: null, + exposureTime: null, + fNumber: null, + fileSizeInByte: null, + focalLength: null, + fps: null, + iso: null, + latitude: null, + lensModel: null, + longitude: null, + make: 'Canon', + model: null, + modifyDate: null, + orientation: null, + profileDescription: null, + projectionType: null, + rating: null, + state: null, + timeZone: null, + }, + type: SyncEntityType.PartnerAssetExifV1, + }, + ]), + ); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]); + + expect(ackSyncResponse).toHaveLength(0); + }); + + it('should not sync partner asset exif for own user', async () => { + const { auth, context, testSync } = await setup(); + + const user2 = await context.createUser(); + await context.partner.create({ sharedById: user2.id, sharedWithId: auth.user.id }); + const asset = TestFactory.asset({ ownerId: auth.user.id }); + const exif = { assetId: asset.id, make: 'Canon' }; + await context.createAsset(asset); + await context.asset.upsertExif(exif); + + await expect(testSync(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1); + await expect(testSync(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toHaveLength(0); + }); + + it('should not sync partner asset exif for unrelated user', async () => { + const { auth, context, testSync } = await setup(); + + const user2 = await context.createUser(); + const user3 = await context.createUser(); + const session = TestFactory.session({ userId: user3.id }); + const authUser3 = TestFactory.auth({ session, user: user3 }); + await context.partner.create({ sharedById: user2.id, sharedWithId: auth.user.id }); + const asset = TestFactory.asset({ ownerId: user3.id }); + const exif = { assetId: asset.id, make: 'Canon' }; + await context.createAsset(asset); + await context.asset.upsertExif(exif); + + await expect(testSync(authUser3, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1); + await expect(testSync(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toHaveLength(0); + }); + }); +}); diff --git a/server/test/medium/specs/user.service.spec.ts b/server/test/medium/specs/user.service.spec.ts new file mode 100644 index 0000000000..6750dd38d8 --- /dev/null +++ b/server/test/medium/specs/user.service.spec.ts @@ -0,0 +1,116 @@ +import { UserService } from 'src/services/user.service'; +import { TestContext, TestFactory } from 'test/factory'; +import { getKyselyDB, newTestService } from 'test/utils'; + +describe.concurrent(UserService.name, () => { + let sut: UserService; + let context: TestContext; + + beforeAll(async () => { + const db = await getKyselyDB(); + context = await TestContext.from(db).withUser({ isAdmin: true }).create(); + ({ sut } = newTestService(UserService, context)); + }); + + describe('create', () => { + it('should create a user', async () => { + const userDto = TestFactory.user(); + + await expect(sut.createUser(userDto)).resolves.toEqual( + expect.objectContaining({ + id: userDto.id, + name: userDto.name, + email: userDto.email, + }), + ); + }); + + it('should reject user with duplicate email', async () => { + const userDto = TestFactory.user(); + const userDto2 = TestFactory.user({ email: userDto.email }); + + await sut.createUser(userDto); + + await expect(sut.createUser(userDto2)).rejects.toThrow('User exists'); + }); + + it('should not return password', async () => { + const user = await sut.createUser(TestFactory.user()); + + expect((user as any).password).toBeUndefined(); + }); + }); + + describe('get', () => { + it('should get a user', async () => { + const userDto = TestFactory.user(); + + await context.createUser(userDto); + + await expect(sut.get(userDto.id)).resolves.toEqual( + expect.objectContaining({ + id: userDto.id, + name: userDto.name, + email: userDto.email, + }), + ); + }); + + it('should not return password', async () => { + const { id } = await context.createUser(); + + const user = await sut.get(id); + + expect((user as any).password).toBeUndefined(); + }); + }); + + describe('updateMe', () => { + it('should update a user', async () => { + const userDto = TestFactory.user(); + const sessionDto = TestFactory.session({ userId: userDto.id }); + const authDto = TestFactory.auth({ user: userDto }); + + const before = await context.createUser(userDto); + await context.createSession(sessionDto); + + const newUserDto = TestFactory.user(); + + const after = await sut.updateMe(authDto, { name: newUserDto.name, email: newUserDto.email }); + + if (!before || !after) { + expect.fail('User should be found'); + } + + expect(before.updatedAt).toBeDefined(); + expect(after.updatedAt).toBeDefined(); + expect(before.updatedAt).not.toEqual(after.updatedAt); + expect(after).toEqual(expect.objectContaining({ name: newUserDto.name, email: newUserDto.email })); + }); + }); + + describe('setLicense', () => { + const userLicense = { + licenseKey: 'IMCL-FF69-TUK1-RWZU-V9Q8-QGQS-S5GC-X4R2-UFK4', + activationKey: + 'KuX8KsktrBSiXpQMAH0zLgA5SpijXVr_PDkzLdWUlAogCTMBZ0I3KCHXK0eE9EEd7harxup8_EHMeqAWeHo5VQzol6LGECpFv585U9asXD4Zc-UXt3mhJr2uhazqipBIBwJA2YhmUCDy8hiyiGsukDQNu9Rg9C77UeoKuZBWVjWUBWG0mc1iRqfvF0faVM20w53czAzlhaMxzVGc3Oimbd7xi_CAMSujF_2y8QpA3X2fOVkQkzdcH9lV0COejl7IyH27zQQ9HrlrXv3Lai5Hw67kNkaSjmunVBxC5PS0TpKoc9SfBJMaAGWnaDbjhjYUrm-8nIDQnoeEAidDXVAdPw', + }; + it('should set a license', async () => { + const userDto = TestFactory.user(); + const sessionDto = TestFactory.session({ userId: userDto.id }); + const authDto = TestFactory.auth({ user: userDto }); + + await context.getFactory().withUser(userDto).withSession(sessionDto).create(); + + await expect(sut.getLicense(authDto)).rejects.toThrowError(); + + const after = await sut.setLicense(authDto, userLicense); + + expect(after.licenseKey).toEqual(userLicense.licenseKey); + expect(after.activationKey).toEqual(userLicense.activationKey); + + const getResponse = await sut.getLicense(authDto); + expect(getResponse).toEqual(after); + }); + }); +}); diff --git a/server/test/medium/specs/version.service.spec.ts b/server/test/medium/specs/version.service.spec.ts new file mode 100644 index 0000000000..5be36b26ba --- /dev/null +++ b/server/test/medium/specs/version.service.spec.ts @@ -0,0 +1,56 @@ +import { serverVersion } from 'src/constants'; +import { JobName } from 'src/enum'; +import { VersionService } from 'src/services/version.service'; +import { TestContext } from 'test/factory'; +import { getKyselyDB, newTestService } from 'test/utils'; + +const setup = async () => { + const db = await getKyselyDB(); + const context = await TestContext.from(db).create(); + const { sut, mocks } = newTestService(VersionService, context); + + return { + context, + sut, + jobMock: mocks.job, + }; +}; + +describe(VersionService.name, () => { + describe.concurrent('onBootstrap', () => { + it('record the current version on startup', async () => { + const { context, sut } = await setup(); + + const itemsBefore = await context.versionHistory.getAll(); + expect(itemsBefore).toHaveLength(0); + + await sut.onBootstrap(); + + const itemsAfter = await context.versionHistory.getAll(); + expect(itemsAfter).toHaveLength(1); + expect(itemsAfter[0]).toEqual({ + createdAt: expect.any(Date), + id: expect.any(String), + version: serverVersion.toString(), + }); + }); + + it('should queue memory creation when upgrading from 1.128.0', async () => { + const { context, jobMock, sut } = await setup(); + + await context.versionHistory.create({ version: 'v1.128.0' }); + await sut.onBootstrap(); + + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.MEMORIES_CREATE }); + }); + + it('should not queue memory creation when upgrading from 1.129.0', async () => { + const { context, jobMock, sut } = await setup(); + + await context.versionHistory.create({ version: 'v1.129.0' }); + await sut.onBootstrap(); + + expect(jobMock.queue).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index 23886e0495..ec5115b839 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -1,7 +1,12 @@ -import { IAccessRepository } from 'src/types'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export type IAccessRepositoryMock = { [K in keyof IAccessRepository]: Mocked }; +type IAccessRepository = { [K in keyof AccessRepository]: RepositoryInterface }; + +export type IAccessRepositoryMock = { + [K in keyof IAccessRepository]: Mocked; +}; export const newAccessRepositoryMock = (): IAccessRepositoryMock => { return { diff --git a/server/test/repositories/activity.repository.mock.ts b/server/test/repositories/activity.repository.mock.ts deleted file mode 100644 index bcc27774e3..0000000000 --- a/server/test/repositories/activity.repository.mock.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IActivityRepository } from 'src/types'; -import { Mocked, vitest } from 'vitest'; - -export const newActivityRepositoryMock = (): Mocked => { - return { - search: vitest.fn(), - create: vitest.fn(), - delete: vitest.fn(), - getStatistics: vitest.fn(), - }; -}; diff --git a/server/test/repositories/album-user.repository.mock.ts b/server/test/repositories/album-user.repository.mock.ts deleted file mode 100644 index aa9436e33d..0000000000 --- a/server/test/repositories/album-user.repository.mock.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { IAlbumUserRepository } from 'src/types'; -import { Mocked } from 'vitest'; - -export const newAlbumUserRepositoryMock = (): Mocked => { - return { - create: vitest.fn(), - delete: vitest.fn(), - update: vitest.fn(), - }; -}; diff --git a/server/test/repositories/album.repository.mock.ts b/server/test/repositories/album.repository.mock.ts deleted file mode 100644 index dd5c3af6a8..0000000000 --- a/server/test/repositories/album.repository.mock.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { Mocked, vitest } from 'vitest'; - -export const newAlbumRepositoryMock = (): Mocked => { - return { - getById: vitest.fn(), - getByAssetId: vitest.fn(), - getMetadataForIds: vitest.fn(), - getOwned: vitest.fn(), - getShared: vitest.fn(), - getNotShared: vitest.fn(), - restoreAll: vitest.fn(), - softDeleteAll: vitest.fn(), - deleteAll: vitest.fn(), - addAssetIds: vitest.fn(), - removeAsset: vitest.fn(), - removeAssetIds: vitest.fn(), - getAssetIds: vitest.fn(), - create: vitest.fn(), - update: vitest.fn(), - delete: vitest.fn(), - updateThumbnails: vitest.fn(), - }; -}; diff --git a/server/test/repositories/api-key.repository.mock.ts b/server/test/repositories/api-key.repository.mock.ts deleted file mode 100644 index 8c471e520f..0000000000 --- a/server/test/repositories/api-key.repository.mock.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { IApiKeyRepository } from 'src/types'; -import { Mocked, vitest } from 'vitest'; - -export const newKeyRepositoryMock = (): Mocked => { - return { - create: vitest.fn(), - update: vitest.fn(), - delete: vitest.fn(), - getKey: vitest.fn(), - getById: vitest.fn(), - getByUserId: vitest.fn(), - }; -}; diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 928a7956c5..19464f7ff2 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -1,9 +1,11 @@ -import { IAssetRepository } from 'src/interfaces/asset.interface'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newAssetRepositoryMock = (): Mocked => { +export const newAssetRepositoryMock = (): Mocked> => { return { create: vitest.fn(), + createAll: vitest.fn(), upsertExif: vitest.fn(), upsertJobStatus: vitest.fn(), getByDayOfYear: vitest.fn(), @@ -22,6 +24,7 @@ export const newAssetRepositoryMock = (): Mocked => { getAll: vitest.fn().mockResolvedValue({ items: [], hasNextPage: false }), getAllByDeviceId: vitest.fn(), getLivePhotoCount: vitest.fn(), + getLibraryAssetCount: vitest.fn(), updateAll: vitest.fn(), updateDuplicates: vitest.fn(), getByLibraryIdAndOriginalPath: vitest.fn(), @@ -38,5 +41,11 @@ export const newAssetRepositoryMock = (): Mocked => { getDuplicates: vitest.fn(), upsertFile: vitest.fn(), upsertFiles: vitest.fn(), + detectOfflineExternalAssets: vitest.fn(), + filterNewExternalAssetPaths: vitest.fn(), + updateByLibraryId: vitest.fn(), + streamStorageTemplateAssets: vitest.fn(), + getStorageTemplateAsset: vitest.fn(), + streamDeletedAssets: vitest.fn(), }; }; diff --git a/server/test/repositories/audit.repository.mock.ts b/server/test/repositories/audit.repository.mock.ts deleted file mode 100644 index 96fe407c96..0000000000 --- a/server/test/repositories/audit.repository.mock.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { IAuditRepository } from 'src/types'; -import { Mocked, vitest } from 'vitest'; - -export const newAuditRepositoryMock = (): Mocked => { - return { - getAfter: vitest.fn(), - removeBefore: vitest.fn(), - }; -}; diff --git a/server/test/repositories/config.repository.mock.ts b/server/test/repositories/config.repository.mock.ts index ab8731ea4d..7c5450c36e 100644 --- a/server/test/repositories/config.repository.mock.ts +++ b/server/test/repositories/config.repository.mock.ts @@ -1,9 +1,6 @@ -import { PostgresJSDialect } from 'kysely-postgres-js'; -import postgres from 'postgres'; -import { ImmichEnvironment, ImmichWorker } from 'src/enum'; -import { DatabaseExtension } from 'src/interfaces/database.interface'; -import { EnvData } from 'src/repositories/config.repository'; -import { IConfigRepository } from 'src/types'; +import { DatabaseExtension, ImmichEnvironment, ImmichWorker } from 'src/enum'; +import { ConfigRepository, EnvData } from 'src/repositories/config.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; const envData: EnvData = { @@ -24,12 +21,7 @@ const envData: EnvData = { database: { config: { - kysely: { - dialect: new PostgresJSDialect({ - postgres: postgres({ database: 'immich', host: 'database', port: 5432 }), - }), - log: ['error'], - }, + kysely: { database: 'immich', host: 'database', port: 5432 }, typeorm: { connectionType: 'parts', database: 'immich', @@ -104,7 +96,7 @@ const envData: EnvData = { }; export const mockEnvData = (config: Partial) => ({ ...envData, ...config }); -export const newConfigRepositoryMock = (): Mocked => { +export const newConfigRepositoryMock = (): Mocked> => { return { getEnv: vitest.fn().mockReturnValue(mockEnvData({})), getWorker: vitest.fn().mockReturnValue(ImmichWorker.API), diff --git a/server/test/repositories/cron.repository.mock.ts b/server/test/repositories/cron.repository.mock.ts deleted file mode 100644 index cc856909c8..0000000000 --- a/server/test/repositories/cron.repository.mock.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ICronRepository } from 'src/types'; -import { Mocked, vitest } from 'vitest'; - -export const newCronRepositoryMock = (): Mocked => { - return { - create: vitest.fn(), - update: vitest.fn(), - }; -}; diff --git a/server/test/repositories/crypto.repository.mock.ts b/server/test/repositories/crypto.repository.mock.ts index e0b6fa2bb8..9d32a88987 100644 --- a/server/test/repositories/crypto.repository.mock.ts +++ b/server/test/repositories/crypto.repository.mock.ts @@ -1,7 +1,8 @@ -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { CryptoRepository } from 'src/repositories/crypto.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newCryptoRepositoryMock = (): Mocked => { +export const newCryptoRepositoryMock = (): Mocked> => { return { randomUUID: vitest.fn().mockReturnValue('random-uuid'), randomBytes: vitest.fn().mockReturnValue(Buffer.from('random-bytes', 'utf8')), diff --git a/server/test/repositories/database.repository.mock.ts b/server/test/repositories/database.repository.mock.ts index c135772518..eeedf682de 100644 --- a/server/test/repositories/database.repository.mock.ts +++ b/server/test/repositories/database.repository.mock.ts @@ -1,11 +1,10 @@ -import { IDatabaseRepository } from 'src/interfaces/database.interface'; +import { DatabaseRepository } from 'src/repositories/database.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newDatabaseRepositoryMock = (): Mocked => { +export const newDatabaseRepositoryMock = (): Mocked> => { return { - init: vitest.fn(), shutdown: vitest.fn(), - reconnect: vitest.fn(), getExtensionVersion: vitest.fn(), getExtensionVersionRange: vitest.fn(), getPostgresVersion: vitest.fn().mockResolvedValue('14.10 (Debian 14.10-1.pgdg120+1)'), diff --git a/server/test/repositories/event.repository.mock.ts b/server/test/repositories/event.repository.mock.ts deleted file mode 100644 index a425ddef3a..0000000000 --- a/server/test/repositories/event.repository.mock.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { IEventRepository } from 'src/interfaces/event.interface'; -import { Mocked, vitest } from 'vitest'; - -export const newEventRepositoryMock = (): Mocked => { - return { - setup: vitest.fn(), - emit: vitest.fn() as any, - clientSend: vitest.fn() as any, - clientBroadcast: vitest.fn() as any, - serverSend: vitest.fn(), - }; -}; diff --git a/server/test/repositories/job.repository.mock.ts b/server/test/repositories/job.repository.mock.ts index e9557af59b..f0f4fdda00 100644 --- a/server/test/repositories/job.repository.mock.ts +++ b/server/test/repositories/job.repository.mock.ts @@ -1,7 +1,8 @@ -import { IJobRepository } from 'src/interfaces/job.interface'; +import { JobRepository } from 'src/repositories/job.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newJobRepositoryMock = (): Mocked => { +export const newJobRepositoryMock = (): Mocked> => { return { setup: vitest.fn(), startWorkers: vitest.fn(), diff --git a/server/test/repositories/library.repository.mock.ts b/server/test/repositories/library.repository.mock.ts deleted file mode 100644 index 83e97c7ffa..0000000000 --- a/server/test/repositories/library.repository.mock.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ILibraryRepository } from 'src/interfaces/library.interface'; -import { Mocked, vitest } from 'vitest'; - -export const newLibraryRepositoryMock = (): Mocked => { - return { - get: vitest.fn(), - create: vitest.fn(), - delete: vitest.fn(), - softDelete: vitest.fn(), - update: vitest.fn(), - getStatistics: vitest.fn(), - getAllDeleted: vitest.fn(), - getAll: vitest.fn(), - }; -}; diff --git a/server/test/repositories/logger.repository.mock.ts b/server/test/repositories/logger.repository.mock.ts deleted file mode 100644 index 0336a66090..0000000000 --- a/server/test/repositories/logger.repository.mock.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ILoggingRepository } from 'src/types'; -import { Mocked, vitest } from 'vitest'; - -export const newLoggingRepositoryMock = (): Mocked => { - return { - setLogLevel: vitest.fn(), - setContext: vitest.fn(), - setAppName: vitest.fn(), - isLevelEnabled: vitest.fn(), - verbose: vitest.fn(), - debug: vitest.fn(), - log: vitest.fn(), - warn: vitest.fn(), - error: vitest.fn(), - fatal: vitest.fn(), - }; -}; diff --git a/server/test/repositories/machine-learning.repository.mock.ts b/server/test/repositories/machine-learning.repository.mock.ts deleted file mode 100644 index 9dd1bdca29..0000000000 --- a/server/test/repositories/machine-learning.repository.mock.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; -import { Mocked, vitest } from 'vitest'; - -export const newMachineLearningRepositoryMock = (): Mocked => { - return { - encodeImage: vitest.fn(), - encodeText: vitest.fn(), - detectFaces: vitest.fn(), - }; -}; diff --git a/server/test/repositories/map.repository.mock.ts b/server/test/repositories/map.repository.mock.ts deleted file mode 100644 index 4b56b9443a..0000000000 --- a/server/test/repositories/map.repository.mock.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { IMapRepository } from 'src/types'; -import { Mocked } from 'vitest'; - -export const newMapRepositoryMock = (): Mocked => { - return { - init: vitest.fn(), - reverseGeocode: vitest.fn(), - getMapMarkers: vitest.fn(), - }; -}; diff --git a/server/test/repositories/media.repository.mock.ts b/server/test/repositories/media.repository.mock.ts index 1e909dcae3..7c651ddef6 100644 --- a/server/test/repositories/media.repository.mock.ts +++ b/server/test/repositories/media.repository.mock.ts @@ -1,10 +1,11 @@ -import { IMediaRepository } from 'src/types'; +import { MediaRepository } from 'src/repositories/media.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newMediaRepositoryMock = (): Mocked => { +export const newMediaRepositoryMock = (): Mocked> => { return { generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()), - generateThumbhash: vitest.fn().mockImplementation(() => Promise.resolve()), + generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')), decodeImage: vitest.fn().mockResolvedValue({ data: Buffer.from(''), info: {} }), extract: vitest.fn().mockResolvedValue(false), probe: vitest.fn(), diff --git a/server/test/repositories/memory.repository.mock.ts b/server/test/repositories/memory.repository.mock.ts deleted file mode 100644 index c818c29195..0000000000 --- a/server/test/repositories/memory.repository.mock.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { IMemoryRepository } from 'src/types'; -import { Mocked, vitest } from 'vitest'; - -export const newMemoryRepositoryMock = (): Mocked => { - return { - search: vitest.fn().mockResolvedValue([]), - get: vitest.fn(), - create: vitest.fn(), - update: vitest.fn(), - delete: vitest.fn(), - getAssetIds: vitest.fn().mockResolvedValue(new Set()), - addAssetIds: vitest.fn(), - removeAssetIds: vitest.fn(), - }; -}; diff --git a/server/test/repositories/metadata.repository.mock.ts b/server/test/repositories/metadata.repository.mock.ts index e9bb68b95b..854f13b841 100644 --- a/server/test/repositories/metadata.repository.mock.ts +++ b/server/test/repositories/metadata.repository.mock.ts @@ -1,8 +1,10 @@ -import { IMetadataRepository } from 'src/types'; +import { MetadataRepository } from 'src/repositories/metadata.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newMetadataRepositoryMock = (): Mocked => { +export const newMetadataRepositoryMock = (): Mocked> => { return { + setMaxConcurrency: vitest.fn(), teardown: vitest.fn(), readTags: vitest.fn(), writeTags: vitest.fn(), diff --git a/server/test/repositories/move.repository.mock.ts b/server/test/repositories/move.repository.mock.ts deleted file mode 100644 index 1f982a048d..0000000000 --- a/server/test/repositories/move.repository.mock.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IMoveRepository } from 'src/interfaces/move.interface'; -import { Mocked, vitest } from 'vitest'; - -export const newMoveRepositoryMock = (): Mocked => { - return { - create: vitest.fn(), - getByEntity: vitest.fn(), - update: vitest.fn(), - delete: vitest.fn(), - }; -}; diff --git a/server/test/repositories/notification.repository.mock.ts b/server/test/repositories/notification.repository.mock.ts deleted file mode 100644 index 2065a0bf3e..0000000000 --- a/server/test/repositories/notification.repository.mock.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { INotificationRepository } from 'src/types'; -import { Mocked } from 'vitest'; - -export const newNotificationRepositoryMock = (): Mocked => { - return { - renderEmail: vitest.fn(), - sendEmail: vitest.fn().mockResolvedValue({ messageId: 'message-1' }), - verifySmtp: vitest.fn(), - }; -}; diff --git a/server/test/repositories/oauth.repository.mock.ts b/server/test/repositories/oauth.repository.mock.ts deleted file mode 100644 index 8980bfb14f..0000000000 --- a/server/test/repositories/oauth.repository.mock.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IOAuthRepository } from 'src/types'; -import { Mocked } from 'vitest'; - -export const newOAuthRepositoryMock = (): Mocked => { - return { - init: vitest.fn(), - authorize: vitest.fn(), - getLogoutEndpoint: vitest.fn(), - getProfile: vitest.fn(), - }; -}; diff --git a/server/test/repositories/partner.repository.mock.ts b/server/test/repositories/partner.repository.mock.ts deleted file mode 100644 index ec1f141075..0000000000 --- a/server/test/repositories/partner.repository.mock.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { IPartnerRepository } from 'src/interfaces/partner.interface'; -import { Mocked, vitest } from 'vitest'; - -export const newPartnerRepositoryMock = (): Mocked => { - return { - create: vitest.fn(), - remove: vitest.fn(), - getAll: vitest.fn().mockResolvedValue([]), - get: vitest.fn(), - update: vitest.fn(), - }; -}; diff --git a/server/test/repositories/person.repository.mock.ts b/server/test/repositories/person.repository.mock.ts deleted file mode 100644 index d7b92d3eab..0000000000 --- a/server/test/repositories/person.repository.mock.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { Mocked, vitest } from 'vitest'; - -export const newPersonRepositoryMock = (): Mocked => { - return { - getById: vitest.fn(), - getAll: vitest.fn(), - getAllForUser: vitest.fn(), - getAllWithoutFaces: vitest.fn(), - - getByName: vitest.fn(), - getDistinctNames: vitest.fn(), - - create: vitest.fn(), - createAll: vitest.fn(), - update: vitest.fn(), - updateAll: vitest.fn(), - delete: vitest.fn(), - deleteFaces: vitest.fn(), - - getStatistics: vitest.fn(), - getAllFaces: vitest.fn(), - getFacesByIds: vitest.fn(), - getRandomFace: vitest.fn(), - - reassignFaces: vitest.fn(), - unassignFaces: vitest.fn(), - refreshFaces: vitest.fn(), - getFaces: vitest.fn(), - reassignFace: vitest.fn(), - getFaceById: vitest.fn(), - getFaceByIdWithAssets: vitest.fn(), - getNumberOfPeople: vitest.fn(), - getLatestFaceDate: vitest.fn(), - }; -}; diff --git a/server/test/repositories/process.repository.mock.ts b/server/test/repositories/process.repository.mock.ts deleted file mode 100644 index 9a3c5a30b6..0000000000 --- a/server/test/repositories/process.repository.mock.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { IProcessRepository } from 'src/interfaces/process.interface'; -import { Mocked, vitest } from 'vitest'; - -export const newProcessRepositoryMock = (): Mocked => { - return { - spawn: vitest.fn(), - }; -}; diff --git a/server/test/repositories/search.repository.mock.ts b/server/test/repositories/search.repository.mock.ts deleted file mode 100644 index be0e753e30..0000000000 --- a/server/test/repositories/search.repository.mock.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ISearchRepository } from 'src/interfaces/search.interface'; -import { Mocked, vitest } from 'vitest'; - -export const newSearchRepositoryMock = (): Mocked => { - return { - searchMetadata: vitest.fn(), - searchSmart: vitest.fn(), - searchDuplicates: vitest.fn(), - searchFaces: vitest.fn(), - searchRandom: vitest.fn(), - upsert: vitest.fn(), - searchPlaces: vitest.fn(), - getAssetsByCity: vitest.fn(), - deleteAllSearchEmbeddings: vitest.fn(), - getDimensionSize: vitest.fn(), - setDimensionSize: vitest.fn(), - getCameraMakes: vitest.fn(), - getCameraModels: vitest.fn(), - getCities: vitest.fn(), - getCountries: vitest.fn(), - getStates: vitest.fn(), - }; -}; diff --git a/server/test/repositories/server-info.repository.mock.ts b/server/test/repositories/server-info.repository.mock.ts deleted file mode 100644 index 5e9ecd1387..0000000000 --- a/server/test/repositories/server-info.repository.mock.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { IServerInfoRepository } from 'src/types'; -import { Mocked, vitest } from 'vitest'; - -export const newServerInfoRepositoryMock = (): Mocked => { - return { - getGitHubRelease: vitest.fn(), - getBuildVersions: vitest.fn(), - }; -}; diff --git a/server/test/repositories/session.repository.mock.ts b/server/test/repositories/session.repository.mock.ts deleted file mode 100644 index e24d4c87dd..0000000000 --- a/server/test/repositories/session.repository.mock.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ISessionRepository } from 'src/interfaces/session.interface'; -import { Mocked, vitest } from 'vitest'; - -export const newSessionRepositoryMock = (): Mocked => { - return { - search: vitest.fn(), - create: vitest.fn() as any, - update: vitest.fn() as any, - delete: vitest.fn(), - getByToken: vitest.fn(), - getByUserId: vitest.fn(), - }; -}; diff --git a/server/test/repositories/shared-link.repository.mock.ts b/server/test/repositories/shared-link.repository.mock.ts deleted file mode 100644 index 251b38d5d7..0000000000 --- a/server/test/repositories/shared-link.repository.mock.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; -import { Mocked, vitest } from 'vitest'; - -export const newSharedLinkRepositoryMock = (): Mocked => { - return { - getAll: vitest.fn(), - get: vitest.fn(), - getByKey: vitest.fn(), - create: vitest.fn(), - remove: vitest.fn(), - update: vitest.fn(), - }; -}; diff --git a/server/test/repositories/stack.repository.mock.ts b/server/test/repositories/stack.repository.mock.ts deleted file mode 100644 index 35d1614de7..0000000000 --- a/server/test/repositories/stack.repository.mock.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { IStackRepository } from 'src/interfaces/stack.interface'; -import { Mocked, vitest } from 'vitest'; - -export const newStackRepositoryMock = (): Mocked => { - return { - search: vitest.fn(), - create: vitest.fn(), - update: vitest.fn(), - delete: vitest.fn(), - getById: vitest.fn(), - deleteAll: vitest.fn(), - }; -}; diff --git a/server/test/repositories/storage.repository.mock.ts b/server/test/repositories/storage.repository.mock.ts index 0af16a8d17..984785d510 100644 --- a/server/test/repositories/storage.repository.mock.ts +++ b/server/test/repositories/storage.repository.mock.ts @@ -1,6 +1,7 @@ import { WatchOptions } from 'chokidar'; import { StorageCore } from 'src/cores/storage.core'; -import { IStorageRepository, WatchEvents } from 'src/interfaces/storage.interface'; +import { StorageRepository, WatchEvents } from 'src/repositories/storage.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; interface MockWatcherOptions { @@ -39,7 +40,7 @@ export const makeMockWatcher = return () => Promise.resolve(); }; -export const newStorageRepositoryMock = (reset = true): Mocked => { +export const newStorageRepositoryMock = (reset = true): Mocked> => { if (reset) { StorageCore.reset(); } diff --git a/server/test/repositories/system-metadata.repository.mock.ts b/server/test/repositories/system-metadata.repository.mock.ts index 793dd4c1c0..ab9e300576 100644 --- a/server/test/repositories/system-metadata.repository.mock.ts +++ b/server/test/repositories/system-metadata.repository.mock.ts @@ -1,8 +1,9 @@ -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; +import { RepositoryInterface } from 'src/types'; import { clearConfigCache } from 'src/utils/config'; import { Mocked, vitest } from 'vitest'; -export const newSystemMetadataRepositoryMock = (): Mocked => { +export const newSystemMetadataRepositoryMock = (): Mocked> => { clearConfigCache(); return { get: vitest.fn() as any, diff --git a/server/test/repositories/tag.repository.mock.ts b/server/test/repositories/tag.repository.mock.ts deleted file mode 100644 index acc2b59f6d..0000000000 --- a/server/test/repositories/tag.repository.mock.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ITagRepository } from 'src/interfaces/tag.interface'; -import { Mocked, vitest } from 'vitest'; - -export const newTagRepositoryMock = (): Mocked => { - return { - getAll: vitest.fn(), - getByValue: vitest.fn(), - upsertValue: vitest.fn(), - upsertAssetTags: vitest.fn(), - - get: vitest.fn(), - create: vitest.fn(), - update: vitest.fn(), - delete: vitest.fn(), - - getAssetIds: vitest.fn(), - addAssetIds: vitest.fn(), - removeAssetIds: vitest.fn(), - upsertAssetIds: vitest.fn(), - deleteEmptyTags: vitest.fn(), - }; -}; diff --git a/server/test/repositories/telemetry.repository.mock.ts b/server/test/repositories/telemetry.repository.mock.ts index afadcea0cf..c7442052da 100644 --- a/server/test/repositories/telemetry.repository.mock.ts +++ b/server/test/repositories/telemetry.repository.mock.ts @@ -1,4 +1,5 @@ -import { ITelemetryRepository, RepositoryInterface } from 'src/types'; +import { TelemetryRepository } from 'src/repositories/telemetry.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; const newMetricGroupMock = () => { @@ -10,6 +11,8 @@ const newMetricGroupMock = () => { }; }; +type ITelemetryRepository = RepositoryInterface; + export type ITelemetryRepositoryMock = { [K in keyof ITelemetryRepository]: Mocked>; }; diff --git a/server/test/repositories/trash.repository.mock.ts b/server/test/repositories/trash.repository.mock.ts deleted file mode 100644 index f983afdce8..0000000000 --- a/server/test/repositories/trash.repository.mock.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ITrashRepository } from 'src/types'; -import { Mocked, vitest } from 'vitest'; - -export const newTrashRepositoryMock = (): Mocked => { - return { - empty: vitest.fn(), - restore: vitest.fn(), - restoreAll: vitest.fn(), - getDeletedIds: vitest.fn(), - }; -}; diff --git a/server/test/repositories/user.repository.mock.ts b/server/test/repositories/user.repository.mock.ts deleted file mode 100644 index 6362ab6a99..0000000000 --- a/server/test/repositories/user.repository.mock.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { IUserRepository } from 'src/interfaces/user.interface'; -import { Mocked, vitest } from 'vitest'; - -export const newUserRepositoryMock = (): Mocked => { - return { - get: vitest.fn(), - getAdmin: vitest.fn(), - getByEmail: vitest.fn(), - getByStorageLabel: vitest.fn(), - getByOAuthId: vitest.fn(), - getUserStats: vitest.fn(), - getList: vitest.fn(), - create: vitest.fn(), - update: vitest.fn(), - delete: vitest.fn(), - getDeletedUsers: vitest.fn(), - hasAdmin: vitest.fn(), - updateUsage: vitest.fn(), - syncUsage: vitest.fn(), - upsertMetadata: vitest.fn(), - deleteMetadata: vitest.fn(), - }; -}; diff --git a/server/test/repositories/version-history.repository.mock.ts b/server/test/repositories/version-history.repository.mock.ts deleted file mode 100644 index 9ff7708796..0000000000 --- a/server/test/repositories/version-history.repository.mock.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { IVersionHistoryRepository } from 'src/types'; -import { Mocked, vitest } from 'vitest'; - -export const newVersionHistoryRepositoryMock = (): Mocked => { - return { - getAll: vitest.fn().mockResolvedValue([]), - getLatest: vitest.fn(), - create: vitest.fn(), - }; -}; diff --git a/server/test/repositories/view.repository.mock.ts b/server/test/repositories/view.repository.mock.ts deleted file mode 100644 index bb58fda8a3..0000000000 --- a/server/test/repositories/view.repository.mock.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { IViewRepository } from 'src/types'; -import { Mocked, vitest } from 'vitest'; - -export const newViewRepositoryMock = (): Mocked => { - return { - getAssetsByOriginalPath: vitest.fn(), - getUniqueOriginalPaths: vitest.fn(), - }; -}; diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts new file mode 100644 index 0000000000..31effb129a --- /dev/null +++ b/server/test/small.factory.ts @@ -0,0 +1,184 @@ +import { randomUUID } from 'node:crypto'; +import { ApiKey, Asset, AuthApiKey, AuthUser, Library, User } from 'src/database'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { OnThisDayData } from 'src/entities/memory.entity'; +import { AssetStatus, AssetType, MemoryType, Permission } from 'src/enum'; +import { ActivityItem, MemoryItem } from 'src/types'; + +export const newUuid = () => randomUUID() as string; +export const newUuids = () => + Array.from({ length: 100 }) + .fill(0) + .map(() => newUuid()); +export const newDate = () => new Date(); +export const newUpdateId = () => 'uuid-v7'; +export const newSha1 = () => Buffer.from('this is a fake hash'); + +const authFactory = ({ apiKey, ...user }: Partial & { apiKey?: Partial } = {}) => { + const auth: AuthDto = { + user: authUserFactory(user), + }; + + if (apiKey) { + auth.apiKey = authApiKeyFactory(apiKey); + } + + return auth; +}; + +const authApiKeyFactory = (apiKey: Partial = {}) => ({ + id: newUuid(), + permissions: [Permission.ALL], + ...apiKey, +}); + +const authUserFactory = (authUser: Partial = {}) => ({ + id: newUuid(), + isAdmin: false, + name: 'Test User', + email: 'test@immich.cloud', + quotaUsageInBytes: 0, + quotaSizeInBytes: null, + ...authUser, +}); + +const sessionFactory = () => ({ + id: newUuid(), + createdAt: newDate(), + updatedAt: newDate(), + updateId: newUpdateId(), + deviceOS: 'android', + deviceType: 'mobile', + token: 'abc123', + userId: newUuid(), +}); + +const stackFactory = () => ({ + id: newUuid(), + ownerId: newUuid(), + primaryAssetId: newUuid(), +}); + +const userFactory = (user: Partial = {}) => ({ + id: newUuid(), + name: 'Test User', + email: 'test@immich.cloud', + profileImagePath: '', + profileChangedAt: newDate(), + ...user, +}); + +const assetFactory = (asset: Partial = {}) => ({ + id: newUuid(), + createdAt: newDate(), + updatedAt: newDate(), + deletedAt: null, + updateId: newUpdateId(), + status: AssetStatus.ACTIVE, + checksum: newSha1(), + deviceAssetId: '', + deviceId: '', + duplicateId: null, + duration: null, + encodedVideoPath: null, + fileCreatedAt: newDate(), + fileModifiedAt: newDate(), + isArchived: false, + isExternal: false, + isFavorite: false, + isOffline: false, + isVisible: true, + libraryId: null, + livePhotoVideoId: null, + localDateTime: newDate(), + originalFileName: 'IMG_123.jpg', + originalPath: `upload/12/34/IMG_123.jpg`, + ownerId: newUuid(), + sidecarPath: null, + stackId: null, + thumbhash: null, + type: AssetType.IMAGE, + ...asset, +}); + +const activityFactory = (activity: Partial = {}) => { + const userId = activity.userId || newUuid(); + return { + id: newUuid(), + comment: null, + isLiked: false, + userId, + user: userFactory({ id: userId }), + assetId: newUuid(), + albumId: newUuid(), + createdAt: newDate(), + updatedAt: newDate(), + updateId: newUpdateId(), + ...activity, + }; +}; + +const apiKeyFactory = (apiKey: Partial = {}) => ({ + id: newUuid(), + userId: newUuid(), + createdAt: newDate(), + updatedAt: newDate(), + updateId: newUpdateId(), + name: 'Api Key', + permissions: [Permission.ALL], + ...apiKey, +}); + +const libraryFactory = (library: Partial = {}) => ({ + id: newUuid(), + createdAt: newDate(), + updatedAt: newDate(), + updateId: newUpdateId(), + deletedAt: null, + refreshedAt: null, + name: 'Library', + assets: [], + ownerId: newUuid(), + importPaths: [], + exclusionPatterns: [], + ...library, +}); + +const memoryFactory = (memory: Partial = {}) => ({ + id: newUuid(), + createdAt: newDate(), + updatedAt: newDate(), + updateId: newUpdateId(), + deletedAt: null, + ownerId: newUuid(), + type: MemoryType.ON_THIS_DAY, + data: { year: 2024 } as OnThisDayData, + isSaved: false, + memoryAt: newDate(), + seenAt: null, + showAt: newDate(), + hideAt: newDate(), + assets: [], + ...memory, +}); + +const versionHistoryFactory = () => ({ + id: newUuid(), + createdAt: newDate(), + version: '1.123.45', +}); + +export const factory = { + activity: activityFactory, + apiKey: apiKeyFactory, + asset: assetFactory, + auth: authFactory, + authApiKey: authApiKeyFactory, + authUser: authUserFactory, + library: libraryFactory, + memory: memoryFactory, + session: sessionFactory, + stack: stackFactory, + user: userFactory, + versionHistory: versionHistoryFactory, +}; diff --git a/server/test/utils.ts b/server/test/utils.ts index 94377ca18c..988d4cd97e 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -1,91 +1,161 @@ +import { ClassConstructor } from 'class-transformer'; +import { Kysely, sql } from 'kysely'; +import { PostgresJSDialect } from 'kysely-postgres-js'; import { ChildProcessWithoutNullStreams } from 'node:child_process'; import { Writable } from 'node:stream'; +import { parse } from 'pg-connection-string'; import { PNG } from 'pngjs'; -import { ImmichWorker } from 'src/enum'; +import postgres, { Notice } from 'postgres'; +import { DB } from 'src/db'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; +import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; import { AuditRepository } from 'src/repositories/audit.repository'; +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 { DownloadRepository } from 'src/repositories/download.repository'; +import { EventRepository } from 'src/repositories/event.repository'; +import { JobRepository } from 'src/repositories/job.repository'; +import { LibraryRepository } from 'src/repositories/library.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; +import { MachineLearningRepository } from 'src/repositories/machine-learning.repository'; import { MapRepository } from 'src/repositories/map.repository'; import { MediaRepository } from 'src/repositories/media.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; +import { MoveRepository } from 'src/repositories/move.repository'; import { NotificationRepository } from 'src/repositories/notification.repository'; import { OAuthRepository } from 'src/repositories/oauth.repository'; +import { PartnerRepository } from 'src/repositories/partner.repository'; +import { PersonRepository } from 'src/repositories/person.repository'; +import { ProcessRepository } from 'src/repositories/process.repository'; +import { SearchRepository } from 'src/repositories/search.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; +import { SessionRepository } from 'src/repositories/session.repository'; +import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; +import { StackRepository } from 'src/repositories/stack.repository'; +import { StorageRepository } from 'src/repositories/storage.repository'; +import { SyncRepository } from 'src/repositories/sync.repository'; +import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; +import { TagRepository } from 'src/repositories/tag.repository'; import { TelemetryRepository } from 'src/repositories/telemetry.repository'; import { TrashRepository } from 'src/repositories/trash.repository'; +import { UserRepository } from 'src/repositories/user.repository'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; import { ViewRepository } from 'src/repositories/view-repository'; import { BaseService } from 'src/services/base.service'; -import { - IAccessRepository, - IActivityRepository, - IAlbumUserRepository, - IApiKeyRepository, - IAuditRepository, - ICronRepository, - ILoggingRepository, - IMapRepository, - IMediaRepository, - IMemoryRepository, - IMetadataRepository, - INotificationRepository, - IOAuthRepository, - IServerInfoRepository, - ITrashRepository, - IVersionHistoryRepository, - IViewRepository, -} from 'src/types'; -import { newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newActivityRepositoryMock } from 'test/repositories/activity.repository.mock'; -import { newAlbumUserRepositoryMock } from 'test/repositories/album-user.repository.mock'; -import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; -import { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock'; +import { RepositoryInterface } from 'src/types'; +import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newAuditRepositoryMock } from 'test/repositories/audit.repository.mock'; import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; -import { newCronRepositoryMock } from 'test/repositories/cron.repository.mock'; import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock'; -import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; -import { newLibraryRepositoryMock } from 'test/repositories/library.repository.mock'; -import { newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock'; -import { newMapRepositoryMock } from 'test/repositories/map.repository.mock'; import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock'; -import { newMemoryRepositoryMock } from 'test/repositories/memory.repository.mock'; import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock'; -import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock'; -import { newNotificationRepositoryMock } from 'test/repositories/notification.repository.mock'; -import { newOAuthRepositoryMock } from 'test/repositories/oauth.repository.mock'; -import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; -import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; -import { newProcessRepositoryMock } from 'test/repositories/process.repository.mock'; -import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock'; -import { newServerInfoRepositoryMock } from 'test/repositories/server-info.repository.mock'; -import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock'; -import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock'; -import { newStackRepositoryMock } from 'test/repositories/stack.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; -import { newTagRepositoryMock } from 'test/repositories/tag.repository.mock'; -import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock'; -import { newTrashRepositoryMock } from 'test/repositories/trash.repository.mock'; -import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; -import { newVersionHistoryRepositoryMock } from 'test/repositories/version-history.repository.mock'; -import { newViewRepositoryMock } from 'test/repositories/view.repository.mock'; +import { ITelemetryRepositoryMock, newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock'; import { Readable } from 'typeorm/platform/PlatformTools'; -import { Mocked, vitest } from 'vitest'; +import { assert, Mocked, vitest } from 'vitest'; -type Overrides = { - worker?: ImmichWorker; - metadataRepository?: MetadataRepository; +const mockFn = (label: string, { strict }: { strict: boolean }) => { + const message = `Called a mock function without a mock implementation (${label})`; + return vitest.fn().mockImplementation(() => { + if (strict) { + assert.fail(message); + } else { + // console.warn(message); + } + }); }; + +export const automock = ( + Dependency: ClassConstructor, + options?: { + args?: ConstructorParameters>; + strict?: boolean; + }, +): Mocked => { + const mock: Record = {}; + const strict = options?.strict ?? true; + const args = options?.args ?? []; + + const instance = new Dependency(...args); + for (const property of Object.getOwnPropertyNames(Dependency.prototype)) { + if (property === 'constructor') { + continue; + } + + const label = `${Dependency.name}.${property}`; + // console.log(`Automocking ${label}`); + + const target = instance[property as keyof T]; + if (typeof target === 'function') { + mock[property] = mockFn(label, { strict }); + continue; + } + } + + return mock as Mocked; +}; + +export type ServiceOverrides = { + access: AccessRepository; + activity: ActivityRepository; + album: AlbumRepository; + albumUser: AlbumUserRepository; + apiKey: ApiKeyRepository; + audit: AuditRepository; + asset: AssetRepository; + config: ConfigRepository; + cron: CronRepository; + crypto: CryptoRepository; + database: DatabaseRepository; + downloadRepository: DownloadRepository; + event: EventRepository; + job: JobRepository; + library: LibraryRepository; + logger: LoggingRepository; + machineLearning: MachineLearningRepository; + map: MapRepository; + media: MediaRepository; + memory: MemoryRepository; + metadata: MetadataRepository; + move: MoveRepository; + notification: NotificationRepository; + oauth: OAuthRepository; + partner: PartnerRepository; + person: PersonRepository; + process: ProcessRepository; + search: SearchRepository; + serverInfo: ServerInfoRepository; + session: SessionRepository; + sharedLink: SharedLinkRepository; + stack: StackRepository; + storage: StorageRepository; + sync: SyncRepository; + systemMetadata: SystemMetadataRepository; + tag: TagRepository; + telemetry: TelemetryRepository; + trash: TrashRepository; + user: UserRepository; + versionHistory: VersionHistoryRepository; + view: ViewRepository; +}; + +type As = T extends RepositoryInterface ? U : never; +type IAccessRepository = { [K in keyof AccessRepository]: RepositoryInterface }; + +export type ServiceMocks = { + [K in keyof Omit]: Mocked>; +} & { access: IAccessRepositoryMock; telemetry: ITelemetryRepositoryMock }; + type BaseServiceArgs = ConstructorParameters; type Constructor> = { new (...deps: Args): Type; @@ -93,133 +163,103 @@ type Constructor> = { export const newTestService = ( Service: Constructor, - overrides?: Overrides, + overrides: Partial = {}, ) => { - const { metadataRepository } = overrides || {}; + const loggerMock = { setContext: () => {} }; + const configMock = { getEnv: () => ({}) }; - const accessMock = newAccessRepositoryMock(); - const loggerMock = newLoggingRepositoryMock(); - const cronMock = newCronRepositoryMock(); - const cryptoMock = newCryptoRepositoryMock(); - const activityMock = newActivityRepositoryMock(); - const auditMock = newAuditRepositoryMock(); - const albumMock = newAlbumRepositoryMock(); - const albumUserMock = newAlbumUserRepositoryMock(); - const assetMock = newAssetRepositoryMock(); - const configMock = newConfigRepositoryMock(); - const databaseMock = newDatabaseRepositoryMock(); - const eventMock = newEventRepositoryMock(); - const jobMock = newJobRepositoryMock(); - const keyMock = newKeyRepositoryMock(); - const libraryMock = newLibraryRepositoryMock(); - const machineLearningMock = newMachineLearningRepositoryMock(); - const mapMock = newMapRepositoryMock(); - const mediaMock = newMediaRepositoryMock(); - const memoryMock = newMemoryRepositoryMock(); - const metadataMock = (metadataRepository || newMetadataRepositoryMock()) as Mocked; - const moveMock = newMoveRepositoryMock(); - const notificationMock = newNotificationRepositoryMock(); - const oauthMock = newOAuthRepositoryMock(); - const partnerMock = newPartnerRepositoryMock(); - const personMock = newPersonRepositoryMock(); - const processMock = newProcessRepositoryMock(); - const searchMock = newSearchRepositoryMock(); - const serverInfoMock = newServerInfoRepositoryMock(); - const sessionMock = newSessionRepositoryMock(); - const sharedLinkMock = newSharedLinkRepositoryMock(); - const stackMock = newStackRepositoryMock(); - const storageMock = newStorageRepositoryMock(); - const systemMock = newSystemMetadataRepositoryMock(); - const tagMock = newTagRepositoryMock(); - const telemetryMock = newTelemetryRepositoryMock(); - const trashMock = newTrashRepositoryMock(); - const userMock = newUserRepositoryMock(); - const versionHistoryMock = newVersionHistoryRepositoryMock(); - const viewMock = newViewRepositoryMock(); + const mocks: ServiceMocks = { + access: newAccessRepositoryMock(), + logger: automock(LoggingRepository, { args: [, configMock], strict: false }), + cron: automock(CronRepository, { args: [, loggerMock] }), + crypto: newCryptoRepositoryMock(), + activity: automock(ActivityRepository), + audit: automock(AuditRepository), + album: automock(AlbumRepository, { strict: false }), + albumUser: automock(AlbumUserRepository), + asset: newAssetRepositoryMock(), + config: newConfigRepositoryMock(), + database: newDatabaseRepositoryMock(), + downloadRepository: automock(DownloadRepository, { strict: false }), + event: automock(EventRepository, { args: [, , loggerMock], strict: false }), + job: newJobRepositoryMock(), + apiKey: automock(ApiKeyRepository), + library: automock(LibraryRepository, { strict: false }), + machineLearning: automock(MachineLearningRepository, { args: [loggerMock], strict: false }), + map: automock(MapRepository, { args: [undefined, undefined, { setContext: () => {} }] }), + media: newMediaRepositoryMock(), + memory: automock(MemoryRepository), + metadata: newMetadataRepositoryMock(), + move: automock(MoveRepository, { strict: false }), + notification: automock(NotificationRepository, { args: [loggerMock] }), + oauth: automock(OAuthRepository, { args: [loggerMock] }), + partner: automock(PartnerRepository, { strict: false }), + person: automock(PersonRepository, { strict: false }), + process: automock(ProcessRepository, { args: [loggerMock] }), + search: automock(SearchRepository, { args: [loggerMock], strict: false }), + serverInfo: automock(ServerInfoRepository, { args: [, loggerMock], strict: false }), + session: automock(SessionRepository), + sharedLink: automock(SharedLinkRepository), + stack: automock(StackRepository), + storage: newStorageRepositoryMock(), + sync: automock(SyncRepository), + systemMetadata: newSystemMetadataRepositoryMock(), + // systemMetadata: automock(SystemMetadataRepository, { strict: false }), + tag: automock(TagRepository, { args: [, loggerMock], strict: false }), + telemetry: newTelemetryRepositoryMock(), + trash: automock(TrashRepository), + user: automock(UserRepository, { strict: false }), + versionHistory: automock(VersionHistoryRepository), + view: automock(ViewRepository), + }; const sut = new Service( - loggerMock as ILoggingRepository as LoggingRepository, - accessMock as IAccessRepository as AccessRepository, - activityMock as IActivityRepository as ActivityRepository, - auditMock as IAuditRepository as AuditRepository, - albumMock, - albumUserMock as IAlbumUserRepository as AlbumUserRepository, - assetMock, - configMock, - cronMock as ICronRepository as CronRepository, - cryptoMock, - databaseMock, - eventMock, - jobMock, - keyMock as IApiKeyRepository as ApiKeyRepository, - libraryMock, - machineLearningMock, - mapMock as IMapRepository as MapRepository, - mediaMock as IMediaRepository as MediaRepository, - memoryMock as IMemoryRepository as MemoryRepository, - metadataMock as IMetadataRepository as MetadataRepository, - moveMock, - notificationMock as INotificationRepository as NotificationRepository, - oauthMock as IOAuthRepository as OAuthRepository, - partnerMock, - personMock, - processMock, - searchMock, - serverInfoMock as IServerInfoRepository as ServerInfoRepository, - sessionMock, - sharedLinkMock, - stackMock, - storageMock, - systemMock, - tagMock, - telemetryMock as unknown as TelemetryRepository, - trashMock as ITrashRepository as TrashRepository, - userMock, - versionHistoryMock as IVersionHistoryRepository as VersionHistoryRepository, - viewMock as IViewRepository as ViewRepository, + overrides.logger || (mocks.logger as As), + overrides.access || (mocks.access as IAccessRepository as AccessRepository), + overrides.activity || (mocks.activity as As), + overrides.album || (mocks.album as As), + overrides.albumUser || (mocks.albumUser as As), + overrides.apiKey || (mocks.apiKey as As), + overrides.asset || (mocks.asset as As), + overrides.audit || (mocks.audit as As), + overrides.config || (mocks.config as As as ConfigRepository), + overrides.cron || (mocks.cron as As), + overrides.crypto || (mocks.crypto as As), + overrides.database || (mocks.database as As), + overrides.downloadRepository || (mocks.downloadRepository as As), + overrides.event || (mocks.event as As), + overrides.job || (mocks.job as As), + overrides.library || (mocks.library as As), + overrides.machineLearning || (mocks.machineLearning as As), + overrides.map || (mocks.map as As), + overrides.media || (mocks.media as As), + overrides.memory || (mocks.memory as As), + overrides.metadata || (mocks.metadata as As), + overrides.move || (mocks.move as As), + overrides.notification || (mocks.notification as As), + overrides.oauth || (mocks.oauth as As), + overrides.partner || (mocks.partner as As), + overrides.person || (mocks.person as As), + overrides.process || (mocks.process as As), + overrides.search || (mocks.search as As), + overrides.serverInfo || (mocks.serverInfo as As), + overrides.session || (mocks.session as As), + overrides.sharedLink || (mocks.sharedLink as As), + overrides.stack || (mocks.stack as As), + overrides.storage || (mocks.storage as As), + overrides.sync || (mocks.sync as As), + overrides.systemMetadata || (mocks.systemMetadata as As), + overrides.tag || (mocks.tag as As), + overrides.telemetry || (mocks.telemetry as unknown as TelemetryRepository), + overrides.trash || (mocks.trash as As), + overrides.user || (mocks.user as As), + overrides.versionHistory || (mocks.versionHistory as As), + overrides.view || (mocks.view as As), ); return { sut, - accessMock, - loggerMock, - cronMock, - cryptoMock, - activityMock, - auditMock, - albumMock, - albumUserMock, - assetMock, - configMock, - databaseMock, - eventMock, - jobMock, - keyMock, - libraryMock, - machineLearningMock, - mapMock, - mediaMock, - memoryMock, - metadataMock, - moveMock, - notificationMock, - oauthMock, - partnerMock, - personMock, - processMock, - searchMock, - serverInfoMock, - sessionMock, - sharedLinkMock, - stackMock, - storageMock, - systemMock, - tagMock, - telemetryMock, - trashMock, - userMock, - versionHistoryMock, - viewMock, + mocks, }; }; @@ -244,6 +284,57 @@ function* newPngFactory() { const pngFactory = newPngFactory(); +export const getKyselyDB = async (suffix?: string): Promise> => { + const parsed = parse(process.env.IMMICH_TEST_POSTGRES_URL!); + + const parsedOptions = { + ...parsed, + ssl: false, + host: parsed.host ?? undefined, + port: parsed.port ? Number(parsed.port) : undefined, + database: parsed.database ?? undefined, + }; + + const driverOptions = { + ...parsedOptions, + onnotice: (notice: Notice) => { + if (notice['severity'] !== 'NOTICE') { + console.warn('Postgres notice:', notice); + } + }, + max: 10, + types: { + date: { + to: 1184, + from: [1082, 1114, 1184], + serialize: (x: Date | string) => (x instanceof Date ? x.toISOString() : x), + parse: (x: string) => new Date(x), + }, + bigint: { + to: 20, + from: [20], + parse: (value: string) => Number.parseInt(value), + serialize: (value: number) => value.toString(), + }, + }, + connection: { + TimeZone: 'UTC', + }, + }; + + const kysely = new Kysely({ + dialect: new PostgresJSDialect({ postgres: postgres({ ...driverOptions, max: 1, database: 'postgres' }) }), + }); + const randomSuffix = Math.random().toString(36).slice(2, 7); + const dbName = `immich_${suffix ?? randomSuffix}`; + + await sql.raw(`CREATE DATABASE ${dbName} WITH TEMPLATE immich OWNER postgres;`).execute(kysely); + + return new Kysely({ + dialect: new PostgresJSDialect({ postgres: postgres({ ...driverOptions, database: dbName }) }), + }); +}; + export const newRandomImage = () => { const { value } = pngFactory.next(); if (!value) { diff --git a/server/vitest.config.medium.mjs b/server/test/vitest.config.medium.mjs similarity index 88% rename from server/vitest.config.medium.mjs rename to server/test/vitest.config.medium.mjs index 40dad8d6a5..fe6a93accb 100644 --- a/server/vitest.config.medium.mjs +++ b/server/test/vitest.config.medium.mjs @@ -7,6 +7,7 @@ export default defineConfig({ root: './', globals: true, include: ['test/medium/**/*.spec.ts'], + globalSetup: ['test/medium/globalSetup.ts'], server: { deps: { fallbackCJS: true, diff --git a/server/vitest.config.mjs b/server/test/vitest.config.mjs similarity index 89% rename from server/vitest.config.mjs rename to server/test/vitest.config.mjs index 92fc027d40..071e4886f2 100644 --- a/server/vitest.config.mjs +++ b/server/test/vitest.config.mjs @@ -2,6 +2,9 @@ import swc from 'unplugin-swc'; import tsconfigPaths from 'vite-tsconfig-paths'; import { defineConfig } from 'vitest/config'; +// Set the timezone to UTC to avoid timezone issues during testing +process.env.TZ = 'UTC'; + export default defineConfig({ test: { root: './', diff --git a/web/.nvmrc b/web/.nvmrc index d5b283a3ac..7d41c735d7 100644 --- a/web/.nvmrc +++ b/web/.nvmrc @@ -1 +1 @@ -22.13.1 +22.14.0 diff --git a/web/Dockerfile b/web/Dockerfile index fc2a9e88c0..8c2e67e62e 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22.13.1-alpine3.20@sha256:c52e20859a92b3eccbd3a36c5e1a90adc20617d8d421d65e8a622e87b5dac963 +FROM node:22.14.0-alpine3.20@sha256:40be979442621049f40b1d51a26b55e281246b5de4e5f51a18da7beb6e17e3f9 RUN apk add --no-cache tini USER node diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index fc5e35ce6d..f855a99c53 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -2,6 +2,8 @@ import { FlatCompat } from '@eslint/eslintrc'; import js from '@eslint/js'; import typescriptEslint from '@typescript-eslint/eslint-plugin'; import tsParser from '@typescript-eslint/parser'; +import eslintPluginSvelte from 'eslint-plugin-svelte'; +import eslintPluginUnicorn from 'eslint-plugin-unicorn'; import globals from 'globals'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -11,11 +13,12 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const compat = new FlatCompat({ baseDirectory: __dirname, - recommendedConfig: js.configs.recommended, - allConfig: js.configs.all, }); export default [ + ...eslintPluginSvelte.configs.recommended, + eslintPluginUnicorn.configs.recommended, + js.configs.recommended, { ignores: [ '**/.DS_Store', @@ -36,15 +39,11 @@ export default [ 'coverage', ], }, - ...compat.extends( - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:svelte/recommended', - 'plugin:unicorn/recommended', - ), + ...compat.extends('plugin:@typescript-eslint/recommended'), { plugins: { '@typescript-eslint': typescriptEslint, + svelte: eslintPluginSvelte, }, languageOptions: { diff --git a/web/package-lock.json b/web/package-lock.json index 34366ca368..d9d457f0f0 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,30 +1,35 @@ { "name": "immich-web", - "version": "1.125.1", + "version": "1.129.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.125.1", + "version": "1.129.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.15.0", + "@immich/ui": "^0.17.0", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", "@photo-sphere-viewer/equirectangular-video-adapter": "^5.11.5", + "@photo-sphere-viewer/resolution-plugin": "^5.11.5", + "@photo-sphere-viewer/settings-plugin": "^5.11.5", "@photo-sphere-viewer/video-plugin": "^5.11.5", "@zoom-image/svelte": "^0.3.0", "dom-to-image": "^2.6.0", + "fabric": "^6.5.4", "handlebars": "^4.7.8", "intl-messageformat": "^10.7.11", "justified-layout": "^4.1.0", "lodash-es": "^4.17.21", "luxon": "^3.4.4", - "socket.io-client": "~4.7.5", + "pmtiles": "^4.3.0", + "qrcode": "^1.5.4", + "socket.io-client": "~4.8.0", "svelte-gestures": "^5.1.3", "svelte-i18n": "^4.0.1", "svelte-local-storage-store": "^0.6.4", @@ -47,6 +52,7 @@ "@types/justified-layout": "^4.1.4", "@types/lodash-es": "^4.17.12", "@types/luxon": "^3.4.2", + "@types/qrcode": "^1.5.5", "@typescript-eslint/eslint-plugin": "^8.20.0", "@typescript-eslint/parser": "^8.20.0", "@vitest/coverage-v8": "^3.0.0", @@ -54,10 +60,10 @@ "dotenv": "^16.4.7", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.0", - "eslint-plugin-svelte": "^2.46.1", - "eslint-plugin-unicorn": "^56.0.1", + "eslint-plugin-svelte": "^3.0.0", + "eslint-plugin-unicorn": "^57.0.0", "factory.ts": "^1.4.1", - "globals": "^15.14.0", + "globals": "^16.0.0", "postcss": "^8.5.0", "prettier": "^3.4.2", "prettier-plugin-organize-imports": "^4.0.0", @@ -75,13 +81,13 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.125.1", + "version": "1.129.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.10.9", + "@types/node": "^22.13.5", "typescript": "^5.3.3" } }, @@ -235,9 +241,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", - "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", + "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", "cpu": [ "ppc64" ], @@ -252,9 +258,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", - "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", + "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", "cpu": [ "arm" ], @@ -269,9 +275,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", - "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", + "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", "cpu": [ "arm64" ], @@ -286,9 +292,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", - "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", + "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", "cpu": [ "x64" ], @@ -303,9 +309,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", - "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", + "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", "cpu": [ "arm64" ], @@ -320,9 +326,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", - "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", + "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", "cpu": [ "x64" ], @@ -337,9 +343,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", - "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", + "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", "cpu": [ "arm64" ], @@ -354,9 +360,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", - "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", + "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", "cpu": [ "x64" ], @@ -371,9 +377,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", - "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", + "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", "cpu": [ "arm" ], @@ -388,9 +394,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", - "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", + "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", "cpu": [ "arm64" ], @@ -405,9 +411,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", - "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", + "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", "cpu": [ "ia32" ], @@ -422,9 +428,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", - "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", + "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", "cpu": [ "loong64" ], @@ -439,9 +445,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", - "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", + "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", "cpu": [ "mips64el" ], @@ -456,9 +462,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", - "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", + "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", "cpu": [ "ppc64" ], @@ -473,9 +479,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", - "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", + "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", "cpu": [ "riscv64" ], @@ -490,9 +496,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", - "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", + "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", "cpu": [ "s390x" ], @@ -507,9 +513,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", - "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", + "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", "cpu": [ "x64" ], @@ -524,9 +530,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", - "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", + "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", "cpu": [ "arm64" ], @@ -541,9 +547,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", - "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", + "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", "cpu": [ "x64" ], @@ -558,9 +564,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", - "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", + "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", "cpu": [ "arm64" ], @@ -575,9 +581,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", - "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", + "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", "cpu": [ "x64" ], @@ -592,9 +598,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", - "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", + "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", "cpu": [ "x64" ], @@ -609,9 +615,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", - "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", + "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", "cpu": [ "arm64" ], @@ -626,9 +632,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", - "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", + "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", "cpu": [ "ia32" ], @@ -643,9 +649,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", - "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", + "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", "cpu": [ "x64" ], @@ -660,16 +666,20 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", + "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", "dev": true, + "license": "MIT", "dependencies": { - "eslint-visitor-keys": "^3.3.0" + "eslint-visitor-keys": "^3.4.3" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, + "funding": { + "url": "https://opencollective.com/eslint" + }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } @@ -685,13 +695,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.1.tgz", - "integrity": "sha512-fo6Mtm5mWyKjA/Chy1BYTdn5mGJoDNjC7C64ug20ADsRDGrA85bN3uK3MaKbeRkRuuIEAR5N33Jr1pbm411/PA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", + "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.5", + "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -700,9 +710,9 @@ } }, "node_modules/@eslint/core": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.10.0.tgz", - "integrity": "sha512-gFHJ+xBOo4G3WRlR1e/3G8A6/KZAH6zcE/hkLRCZTi/B9avAG365QhFA8uOGzTMqgTghpn7/fSnscW++dpMSAw==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", + "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -713,9 +723,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz", - "integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.0.tgz", + "integrity": "sha512-yaVPAiNAalnCZedKLdR21GOGILMLKPyqSLWaAjQFvYA2i/ciDi8ArYVr69Anohb6cH2Ukhqti4aFnYyPm8wdwQ==", "dev": true, "license": "MIT", "dependencies": { @@ -736,37 +746,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.14.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -781,9 +760,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.18.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.18.0.tgz", - "integrity": "sha512-fK6L7rxcq6/z+AaQMtiFTkvbHkBLNlwyRxHpKawP0x3u9+NC6MQTnFW+AdpwC6gfHTW0051cokQgtTN2FqlxQA==", + "version": "9.21.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.21.0.tgz", + "integrity": "sha512-BqStZ3HX8Yz6LvsF5ByXYrtigrV5AXADWLAGc7PH/1SxOb7/FIYYMszZZWiUou/GB9P2lXWk2SV4d+Z8h0nknw==", "dev": true, "license": "MIT", "engines": { @@ -791,9 +770,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.5.tgz", - "integrity": "sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -801,13 +780,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.5.tgz", - "integrity": "sha512-lB05FkqEdUg2AA0xEbUz0SnkXT1LcCTa438W4IWTUh4hdOnVbQyOJ81OrDXsJk/LSiJHubgGEFoR5EHq1NsH1A==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.7.tgz", + "integrity": "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.10.0", + "@eslint/core": "^0.12.0", "levn": "^0.4.1" }, "engines": { @@ -815,9 +794,9 @@ } }, "node_modules/@faker-js/faker": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.4.0.tgz", - "integrity": "sha512-85+k0AxaZSTowL0gXp8zYWDIrWclTbRPg/pm/V0dSFZ6W6D4lhcG3uuZl4zLsEKfEvs69xDbLN2cHQudwp95JA==", + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.5.1.tgz", + "integrity": "sha512-0fzMEDxkExR2cn731kpDaCCnBGBUOIXEi2S1N5l8Hltp6aPf4soTMJ+g4k8r2sI5oB+rpwIW8Uy/6jkwGpnWPg==", "dev": true, "funding": [ { @@ -857,13 +836,13 @@ "license": "MIT" }, "node_modules/@formatjs/ecma402-abstract": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.2.tgz", - "integrity": "sha512-6sE5nyvDloULiyOMbOTJEEgWL32w+VHkZQs8S02Lnn8Y/O5aQhjOEXwWzvR7SsBE/exxlSpY2EsWZgqHbtLatg==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.3.tgz", + "integrity": "sha512-pJT1OkhplSmvvr6i3CWTPvC/FGC06MbN5TNBfRO6Ox62AEz90eMq+dVvtX9Bl3jxCEkS0tATzDarRZuOLw7oFg==", "license": "MIT", "dependencies": { "@formatjs/fast-memoize": "2.2.6", - "@formatjs/intl-localematcher": "0.5.10", + "@formatjs/intl-localematcher": "0.6.0", "decimal.js": "10", "tslib": "2" } @@ -878,30 +857,30 @@ } }, "node_modules/@formatjs/icu-messageformat-parser": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.10.0.tgz", - "integrity": "sha512-PDeky6nDAyHYEtmSi2X1PG9YpqE+2BRTJT7JvPix8K8JX1wBWQNao6KcPtmZpttQHUHmzMcd/rne7lFesSzUKQ==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.1.tgz", + "integrity": "sha512-o0AhSNaOfKoic0Sn1GkFCK4MxdRsw7mPJ5/rBpIqdvcC7MIuyUSW8WChUEvrK78HhNpYOgqCQbINxCTumJLzZA==", "license": "MIT", "dependencies": { - "@formatjs/ecma402-abstract": "2.3.2", - "@formatjs/icu-skeleton-parser": "1.8.12", + "@formatjs/ecma402-abstract": "2.3.3", + "@formatjs/icu-skeleton-parser": "1.8.13", "tslib": "2" } }, "node_modules/@formatjs/icu-skeleton-parser": { - "version": "1.8.12", - "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.12.tgz", - "integrity": "sha512-QRAY2jC1BomFQHYDMcZtClqHR55EEnB96V7Xbk/UiBodsuFc5kujybzt87+qj1KqmJozFhk6n4KiT1HKwAkcfg==", + "version": "1.8.13", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.13.tgz", + "integrity": "sha512-N/LIdTvVc1TpJmMt2jVg0Fr1F7Q1qJPdZSCs19unMskCmVQ/sa0H9L8PWt13vq+gLdLg1+pPsvBLydL1Apahjg==", "license": "MIT", "dependencies": { - "@formatjs/ecma402-abstract": "2.3.2", + "@formatjs/ecma402-abstract": "2.3.3", "tslib": "2" } }, "node_modules/@formatjs/intl-localematcher": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.10.tgz", - "integrity": "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.0.tgz", + "integrity": "sha512-4rB4g+3hESy1bHSBG3tDFaMY2CH67iT7yne1e+0CLTsGLDcmoEWWpJjjpWVaYgYfYuohIRuo0E+N536gd2ZHZA==", "license": "MIT", "dependencies": { "tslib": "2" @@ -1343,9 +1322,9 @@ "link": true }, "node_modules/@immich/ui": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.15.0.tgz", - "integrity": "sha512-vGDNEOGj5Ma/BAIgj31M1roAVoEOVWws5lkgt1xPlIxSHk4pMhGRFMQaJaCsfXeX/nTRsQCd3gOk7Yo0XNrVfg==", + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.17.1.tgz", + "integrity": "sha512-VB8a4t09paza6QoUnGtOsFG/XcjBfRqSiPsG2eUKRIavlpBYuNB5INZXV/207EfWX9TVnQND7ZitdTi39bZDvg==", "license": "GNU Affero General Public License version 3", "dependencies": { "@mdi/js": "^7.4.47", @@ -1554,6 +1533,80 @@ "mapbox-gl": ">=0.32.1 <2.0.0" } }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@mapbox/point-geometry": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", @@ -1651,31 +1704,50 @@ } }, "node_modules/@photo-sphere-viewer/core": { - "version": "5.11.5", - "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/core/-/core-5.11.5.tgz", - "integrity": "sha512-aCo+zsWR0m0qSlNQpkacnQoSfc0An0zujBpQJ5l9LSvZixeC85FTWm9OZs1yaXvE5bM+TsdqfPDojjy9xT8qzQ==", + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/core/-/core-5.12.1.tgz", + "integrity": "sha512-aK+SueXdKOr5FQAMwjxswHaa2OZcpWi4tx5P4fjq1vWEDa8PtdaoSdQaAp3Csmthvd9DlfNDUb6c21fTudzM/w==", "license": "MIT", "dependencies": { - "three": "^0.169.0" + "three": "^0.173.0" } }, "node_modules/@photo-sphere-viewer/equirectangular-video-adapter": { - "version": "5.11.5", - "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/equirectangular-video-adapter/-/equirectangular-video-adapter-5.11.5.tgz", - "integrity": "sha512-OmB5lYtJCHAbOI06X5KABILsjfLhmWp17uEvS9FQrHWX5cYjsSn+T2flBfgxqrVi0gXFSYmh80yoF3tgWjoc9Q==", + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/equirectangular-video-adapter/-/equirectangular-video-adapter-5.12.1.tgz", + "integrity": "sha512-mJIArKtmM72ZQWgkXQUFdg5UAH8xTt3QpmNzMhxgO+RSowLnFov5qA3aG2DbpYc6usRiSxIwRc6LZwlCwbWVQw==", "license": "MIT", "peerDependencies": { - "@photo-sphere-viewer/core": "5.11.5", - "@photo-sphere-viewer/video-plugin": "5.11.5" + "@photo-sphere-viewer/core": "5.12.1", + "@photo-sphere-viewer/video-plugin": "5.12.1" + } + }, + "node_modules/@photo-sphere-viewer/resolution-plugin": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/resolution-plugin/-/resolution-plugin-5.12.1.tgz", + "integrity": "sha512-kJmw4c9zc0BAmKRprauxKOxRBA4Z+hZLKvNBUEmLlcjwp5k3p3R9tW3wphsHavfr5cSdC0BUEk4WRPs9p3oU6w==", + "license": "MIT", + "peerDependencies": { + "@photo-sphere-viewer/core": "5.12.1", + "@photo-sphere-viewer/settings-plugin": "5.12.1" + } + }, + "node_modules/@photo-sphere-viewer/settings-plugin": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/settings-plugin/-/settings-plugin-5.12.1.tgz", + "integrity": "sha512-WpDT3t/4tJLkJGq7Z4MlebL3hLjJ/buXPwVpPm3j+akxtou0xhCU0pwoC/MDXb+HjchG4b4ZTpNza4jfvxLueA==", + "license": "MIT", + "peerDependencies": { + "@photo-sphere-viewer/core": "5.12.1" } }, "node_modules/@photo-sphere-viewer/video-plugin": { - "version": "5.11.5", - "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/video-plugin/-/video-plugin-5.11.5.tgz", - "integrity": "sha512-Jlbx01y3HGwCVlaXPzeMl/LQ2Vqy9LLO9qxmBGoX/aHAse9QsMatl1N1+EUcDZcC4rZcCsNja9OxRN2r/hfnDA==", + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/video-plugin/-/video-plugin-5.12.1.tgz", + "integrity": "sha512-Rynni9E9u1CgQ3JnONDt0rVndaJXRtaDxMXhFgWspq1WcXiU7w1gYmctOgUBlBoZFq5ms5QYRVgBp36/+S7f4Q==", "license": "MIT", "peerDependencies": { - "@photo-sphere-viewer/core": "5.11.5" + "@photo-sphere-viewer/core": "5.12.1" } }, "node_modules/@pkgjs/parseargs": { @@ -1722,9 +1794,9 @@ "dev": true }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.28.1.tgz", - "integrity": "sha512-2aZp8AES04KI2dy3Ss6/MDjXbwBzj+i0GqKtWXgw2/Ma6E4jJvujryO6gJAghIRVz7Vwr9Gtl/8na3nDUKpraQ==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.6.tgz", + "integrity": "sha512-+GcCXtOQoWuC7hhX1P00LqjjIiS/iOouHXhMdiDSnq/1DGTox4SpUvO52Xm+div6+106r+TcvOeo/cxvyEyTgg==", "cpu": [ "arm" ], @@ -1736,9 +1808,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.28.1.tgz", - "integrity": "sha512-EbkK285O+1YMrg57xVA+Dp0tDBRB93/BZKph9XhMjezf6F4TpYjaUSuPt5J0fZXlSag0LmZAsTmdGGqPp4pQFA==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.6.tgz", + "integrity": "sha512-E8+2qCIjciYUnCa1AiVF1BkRgqIGW9KzJeesQqVfyRITGQN+dFuoivO0hnro1DjT74wXLRZ7QF8MIbz+luGaJA==", "cpu": [ "arm64" ], @@ -1750,9 +1822,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.28.1.tgz", - "integrity": "sha512-prduvrMKU6NzMq6nxzQw445zXgaDBbMQvmKSJaxpaZ5R1QDM8w+eGxo6Y/jhT/cLoCvnZI42oEqf9KQNYz1fqQ==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.6.tgz", + "integrity": "sha512-z9Ib+OzqN3DZEjX7PDQMHEhtF+t6Mi2z/ueChQPLS/qUMKY7Ybn5A2ggFoKRNRh1q1T03YTQfBTQCJZiepESAg==", "cpu": [ "arm64" ], @@ -1764,9 +1836,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.28.1.tgz", - "integrity": "sha512-WsvbOunsUk0wccO/TV4o7IKgloJ942hVFK1CLatwv6TJspcCZb9umQkPdvB7FihmdxgaKR5JyxDjWpCOp4uZlQ==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.6.tgz", + "integrity": "sha512-PShKVY4u0FDAR7jskyFIYVyHEPCPnIQY8s5OcXkdU8mz3Y7eXDJPdyM/ZWjkYdR2m0izD9HHWA8sGcXn+Qrsyg==", "cpu": [ "x64" ], @@ -1778,9 +1850,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.28.1.tgz", - "integrity": "sha512-HTDPdY1caUcU4qK23FeeGxCdJF64cKkqajU0iBnTVxS8F7H/7BewvYoG+va1KPSL63kQ1PGNyiwKOfReavzvNA==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.6.tgz", + "integrity": "sha512-YSwyOqlDAdKqs0iKuqvRHLN4SrD2TiswfoLfvYXseKbL47ht1grQpq46MSiQAx6rQEN8o8URtpXARCpqabqxGQ==", "cpu": [ "arm64" ], @@ -1792,9 +1864,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.28.1.tgz", - "integrity": "sha512-m/uYasxkUevcFTeRSM9TeLyPe2QDuqtjkeoTpP9SW0XxUWfcYrGDMkO/m2tTw+4NMAF9P2fU3Mw4ahNvo7QmsQ==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.6.tgz", + "integrity": "sha512-HEP4CgPAY1RxXwwL5sPFv6BBM3tVeLnshF03HMhJYCNc6kvSqBgTMmsEjb72RkZBAWIqiPUyF1JpEBv5XT9wKQ==", "cpu": [ "x64" ], @@ -1806,9 +1878,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.28.1.tgz", - "integrity": "sha512-QAg11ZIt6mcmzpNE6JZBpKfJaKkqTm1A9+y9O+frdZJEuhQxiugM05gnCWiANHj4RmbgeVJpTdmKRmH/a+0QbA==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.6.tgz", + "integrity": "sha512-88fSzjC5xeH9S2Vg3rPgXJULkHcLYMkh8faix8DX4h4TIAL65ekwuQMA/g2CXq8W+NJC43V6fUpYZNjaX3+IIg==", "cpu": [ "arm" ], @@ -1820,9 +1892,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.28.1.tgz", - "integrity": "sha512-dRP9PEBfolq1dmMcFqbEPSd9VlRuVWEGSmbxVEfiq2cs2jlZAl0YNxFzAQS2OrQmsLBLAATDMb3Z6MFv5vOcXg==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.6.tgz", + "integrity": "sha512-wM4ztnutBqYFyvNeR7Av+reWI/enK9tDOTKNF+6Kk2Q96k9bwhDDOlnCUNRPvromlVXo04riSliMBs/Z7RteEg==", "cpu": [ "arm" ], @@ -1834,9 +1906,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.28.1.tgz", - "integrity": "sha512-uGr8khxO+CKT4XU8ZUH1TTEUtlktK6Kgtv0+6bIFSeiSlnGJHG1tSFSjm41uQ9sAO/5ULx9mWOz70jYLyv1QkA==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.6.tgz", + "integrity": "sha512-9RyprECbRa9zEjXLtvvshhw4CMrRa3K+0wcp3KME0zmBe1ILmvcVHnypZ/aIDXpRyfhSYSuN4EPdCCj5Du8FIA==", "cpu": [ "arm64" ], @@ -1848,9 +1920,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.28.1.tgz", - "integrity": "sha512-QF54q8MYGAqMLrX2t7tNpi01nvq5RI59UBNx+3+37zoKX5KViPo/gk2QLhsuqok05sSCRluj0D00LzCwBikb0A==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.6.tgz", + "integrity": "sha512-qTmklhCTyaJSB05S+iSovfo++EwnIEZxHkzv5dep4qoszUMX5Ca4WM4zAVUMbfdviLgCSQOu5oU8YoGk1s6M9Q==", "cpu": [ "arm64" ], @@ -1862,9 +1934,9 @@ ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.28.1.tgz", - "integrity": "sha512-vPul4uodvWvLhRco2w0GcyZcdyBfpfDRgNKU+p35AWEbJ/HPs1tOUrkSueVbBS0RQHAf/A+nNtDpvw95PeVKOA==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.6.tgz", + "integrity": "sha512-4Qmkaps9yqmpjY5pvpkfOerYgKNUGzQpFxV6rnS7c/JfYbDSU0y6WpbbredB5cCpLFGJEqYX40WUmxMkwhWCjw==", "cpu": [ "loong64" ], @@ -1876,9 +1948,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.28.1.tgz", - "integrity": "sha512-pTnTdBuC2+pt1Rmm2SV7JWRqzhYpEILML4PKODqLz+C7Ou2apEV52h19CR7es+u04KlqplggmN9sqZlekg3R1A==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.6.tgz", + "integrity": "sha512-Zsrtux3PuaxuBTX/zHdLaFmcofWGzaWW1scwLU3ZbW/X+hSsFbz9wDIp6XvnT7pzYRl9MezWqEqKy7ssmDEnuQ==", "cpu": [ "ppc64" ], @@ -1890,9 +1962,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.28.1.tgz", - "integrity": "sha512-vWXy1Nfg7TPBSuAncfInmAI/WZDd5vOklyLJDdIRKABcZWojNDY0NJwruY2AcnCLnRJKSaBgf/GiJfauu8cQZA==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.6.tgz", + "integrity": "sha512-aK+Zp+CRM55iPrlyKiU3/zyhgzWBxLVrw2mwiQSYJRobCURb781+XstzvA8Gkjg/hbdQFuDw44aUOxVQFycrAg==", "cpu": [ "riscv64" ], @@ -1904,9 +1976,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.28.1.tgz", - "integrity": "sha512-/yqC2Y53oZjb0yz8PVuGOQQNOTwxcizudunl/tFs1aLvObTclTwZ0JhXF2XcPT/zuaymemCDSuuUPXJJyqeDOg==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.6.tgz", + "integrity": "sha512-WoKLVrY9ogmaYPXwTH326+ErlCIgMmsoRSx6bO+l68YgJnlOXhygDYSZe/qbUJCSiCiZAQ+tKm88NcWuUXqOzw==", "cpu": [ "s390x" ], @@ -1918,9 +1990,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.28.1.tgz", - "integrity": "sha512-fzgeABz7rrAlKYB0y2kSEiURrI0691CSL0+KXwKwhxvj92VULEDQLpBYLHpF49MSiPG4sq5CK3qHMnb9tlCjBw==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.6.tgz", + "integrity": "sha512-Sht4aFvmA4ToHd2vFzwMFaQCiYm2lDFho5rPcvPBT5pCdC+GwHG6CMch4GQfmWTQ1SwRKS0dhDYb54khSrjDWw==", "cpu": [ "x64" ], @@ -1932,9 +2004,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.28.1.tgz", - "integrity": "sha512-xQTDVzSGiMlSshpJCtudbWyRfLaNiVPXt1WgdWTwWz9n0U12cI2ZVtWe/Jgwyv/6wjL7b66uu61Vg0POWVfz4g==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.6.tgz", + "integrity": "sha512-zmmpOQh8vXc2QITsnCiODCDGXFC8LMi64+/oPpPx5qz3pqv0s6x46ps4xoycfUiVZps5PFn1gksZzo4RGTKT+A==", "cpu": [ "x64" ], @@ -1946,9 +2018,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.28.1.tgz", - "integrity": "sha512-wSXmDRVupJstFP7elGMgv+2HqXelQhuNf+IS4V+nUpNVi/GUiBgDmfwD0UGN3pcAnWsgKG3I52wMOBnk1VHr/A==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.6.tgz", + "integrity": "sha512-3/q1qUsO/tLqGBaD4uXsB6coVGB3usxw3qyeVb59aArCgedSF66MPdgRStUd7vbZOsko/CgVaY5fo2vkvPLWiA==", "cpu": [ "arm64" ], @@ -1960,9 +2032,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.28.1.tgz", - "integrity": "sha512-ZkyTJ/9vkgrE/Rk9vhMXhf8l9D+eAhbAVbsGsXKy2ohmJaWg0LPQLnIxRdRp/bKyr8tXuPlXhIoGlEB5XpJnGA==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.6.tgz", + "integrity": "sha512-oLHxuyywc6efdKVTxvc0135zPrRdtYVjtVD5GUm55I3ODxhU/PwkQFD97z16Xzxa1Fz0AEe4W/2hzRtd+IfpOA==", "cpu": [ "ia32" ], @@ -1974,9 +2046,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.28.1.tgz", - "integrity": "sha512-ZvK2jBafvttJjoIdKm/Q/Bh7IJ1Ose9IBOwpOXcOvW3ikGTQGmKDgxTC6oCAzW6PynbkKP8+um1du81XJHZ0JA==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.6.tgz", + "integrity": "sha512-0PVwmgzZ8+TZ9oGBmdZoQVXflbvuwzN/HRclujpl4N/q3i+y0lqLw8n1bXA8ru3sApDjlmONaNAuYr38y1Kr9w==", "cpu": [ "x64" ], @@ -2021,9 +2093,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.16.0.tgz", - "integrity": "sha512-S9i1ZWKqluzoaJ6riYnEdbe+xJluMTMkhABouBa66GaWcAyCjW/jAc0NdJQJ/DXyK1CnP5quBW25e99MNyvLxA==", + "version": "2.17.3", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.17.3.tgz", + "integrity": "sha512-GcNaPDr0ti4O/TonPewkML2DG7UVXkSxPN3nPMlpmx0Rs4b2kVP4gymz98WEHlfzPXdd4uOOT1Js26DtieTNBQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2310,9 +2382,9 @@ } }, "node_modules/@testing-library/svelte": { - "version": "5.2.6", - "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.2.6.tgz", - "integrity": "sha512-1Y8cEg/BtV4J6g9irkY0ksz+ueDFYLiikjTLiqvQPkOUeDzR4gg2zECBf8yrOrCy3e2TAOYMcaysFa0bQMyk1w==", + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.2.7.tgz", + "integrity": "sha512-aGhUaFmEXEVost4QOsbHUUbHLwi7ZZRRxAHFDO2Cmr0BZD3/3+XvaYEPq70Rdw0NRNjdqZHdARBEcrCOkPuAqw==", "dev": true, "license": "MIT", "dependencies": { @@ -2336,9 +2408,9 @@ } }, "node_modules/@testing-library/user-event": { - "version": "14.6.0", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.0.tgz", - "integrity": "sha512-+jsfK7kVJbqnCYtLTln8Ja/NmVrZRwBJHmHR9IxIVccMWSOZ6Oy0FkDJNeyVu4QSpMNmRfy10Xb76ObRDlWWBQ==", + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", "dev": true, "license": "MIT", "engines": { @@ -2349,6 +2421,16 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + } + }, "node_modules/@types/aria-query": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.2.tgz", @@ -2400,9 +2482,10 @@ "dev": true }, "node_modules/@types/leaflet": { - "version": "1.9.8", - "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.8.tgz", - "integrity": "sha512-EXdsL4EhoUtGm2GC2ZYtXn+Fzc6pluVgagvo2VC1RHWToLGlTRwVYoDpqS/7QXa01rmDyBjJk3Catpf60VMkwg==", + "version": "1.9.16", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.16.tgz", + "integrity": "sha512-wzZoyySUxkgMZ0ihJ7IaUIblG8Rdc8AbbZKLneyn+QjYsj5q1QU7TEKYqwTr10BGSzY5LI7tJk9Ifo+mEjdFRw==", + "license": "MIT", "dependencies": { "@types/geojson": "*" } @@ -2447,21 +2530,30 @@ "version": "20.8.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.2.tgz", "integrity": "sha512-Vvycsc9FQdwhxE3y3DzeIxuEJbWGDsnrxvMADzTDF/lcdR9/K+AQIeAghTQsHtotg/q0j3WEOYS/jQgSdWue3w==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/pbf": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==" }, + "node_modules/@types/qrcode": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz", + "integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/supercluster": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", @@ -2471,21 +2563,21 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.20.0.tgz", - "integrity": "sha512-naduuphVw5StFfqp4Gq4WhIBE2gN1GEmMUExpJYknZJdRnc+2gDzB8Z3+5+/Kv33hPQRDGzQO/0opHE72lZZ6A==", + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.25.0.tgz", + "integrity": "sha512-VM7bpzAe7JO/BFf40pIT1lJqS/z1F8OaSsUB3rpFJucQA4cOSuH2RVVVkFULN+En0Djgr29/jb4EQnedUo95KA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/type-utils": "8.20.0", - "@typescript-eslint/utils": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", + "@typescript-eslint/scope-manager": "8.25.0", + "@typescript-eslint/type-utils": "8.25.0", + "@typescript-eslint/utils": "8.25.0", + "@typescript-eslint/visitor-keys": "8.25.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2500,60 +2592,17 @@ "typescript": ">=4.8.4 <5.8.0" } }, - "node_modules/@typescript-eslint/parser": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.20.0.tgz", - "integrity": "sha512-gKXG7A5HMyjDIedBi6bUrDcun8GIjnI8qOwVLiY3rx6T/sHP/19XLJOnIq/FgQvWLHja5JN/LSE7eklNBr612g==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": { + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.25.0.tgz", + "integrity": "sha512-d77dHgHWnxmXOPJuDWO4FDWADmGQkN5+tt6SFRZz/RtCWl4pHgFl3+WdYCn16+3teG09DY6XtEpf3gGD0a186g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/typescript-estree": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.20.0.tgz", - "integrity": "sha512-J7+VkpeGzhOt3FeG1+SzhiMj9NzGD/M6KoGn9f4dbz3YzK9hvbhVTmLj/HiTp9DazIzJ8B4XcM80LrR9Dm1rJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.20.0.tgz", - "integrity": "sha512-bPC+j71GGvA7rVNAHAtOjbVXbLN5PkwqMvy1cwGeaxUoRQXVuKCebRoLzm+IPW/NtFFpstn1ummSIasD5t60GA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/typescript-estree": "8.20.0", - "@typescript-eslint/utils": "8.20.0", + "@typescript-eslint/typescript-estree": "8.25.0", + "@typescript-eslint/utils": "8.25.0", "debug": "^4.3.4", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2567,35 +2616,21 @@ "typescript": ">=4.8.4 <5.8.0" } }, - "node_modules/@typescript-eslint/types": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.20.0.tgz", - "integrity": "sha512-cqaMiY72CkP+2xZRrFt3ExRBu0WmVitN/rYPZErA80mHjHx/Svgp8yfbzkJmDoQ/whcytOPO9/IZXnOc+wigRA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.20.0.tgz", - "integrity": "sha512-Y7ncuy78bJqHI35NwzWol8E0X7XkRVS4K4P4TCyzWkOJih5NDvtoRDW4Ba9YJJoB2igm9yXDdYI/+fkiiAxPzA==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.25.0.tgz", + "integrity": "sha512-ZPaiAKEZ6Blt/TPAx5Ot0EIB/yGtLI2EsGoY6F7XKklfMxYQyvtL+gT/UCqkMzO0BVFHLDlzvFqQzurYahxv9Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", + "@typescript-eslint/types": "8.25.0", + "@typescript-eslint/visitor-keys": "8.25.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2608,7 +2643,58 @@ "typescript": ">=4.8.4 <5.8.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.25.0.tgz", + "integrity": "sha512-syqRbrEv0J1wywiLsK60XzHnQe/kRViI3zwFALrNEgnntn1l24Ra2KvOAWwWbWZ1lBZxZljPDGOq967dsl6fkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.25.0", + "@typescript-eslint/types": "8.25.0", + "@typescript-eslint/typescript-estree": "8.25.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.25.0.tgz", + "integrity": "sha512-ZPaiAKEZ6Blt/TPAx5Ot0EIB/yGtLI2EsGoY6F7XKklfMxYQyvtL+gT/UCqkMzO0BVFHLDlzvFqQzurYahxv9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.25.0", + "@typescript-eslint/visitor-keys": "8.25.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", @@ -2618,7 +2704,7 @@ "balanced-match": "^1.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "node_modules/@typescript-eslint/eslint-plugin/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", @@ -2634,17 +2720,18 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/utils": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.20.0.tgz", - "integrity": "sha512-dq70RUw6UK9ei7vxc4KQtBRk7qkHZv447OUZ6RPQMQl71I3NZxQJX/f32Smr+iqWrB02pHKn2yAdHBb0KNrRMA==", + "node_modules/@typescript-eslint/parser": { + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.25.0.tgz", + "integrity": "sha512-4gbs64bnbSzu4FpgMiQ1A+D+urxkoJk/kqlDJ2W//5SygaEiAP2B4GoS7TEdxgwol2el03gckFV9lJ4QOMiiHg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/typescript-estree": "8.20.0" + "@typescript-eslint/scope-manager": "8.25.0", + "@typescript-eslint/types": "8.25.0", + "@typescript-eslint/typescript-estree": "8.25.0", + "@typescript-eslint/visitor-keys": "8.25.0", + "debug": "^4.3.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2658,14 +2745,99 @@ "typescript": ">=4.8.4 <5.8.0" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.20.0.tgz", - "integrity": "sha512-v/BpkeeYAsPkKCkR8BDwcno0llhzWVqPOamQrAEMdpZav2Y9OVjd9dwJyBLJWwf335B5DmlifECIkZRJCaGaHA==", + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.25.0.tgz", + "integrity": "sha512-ZPaiAKEZ6Blt/TPAx5Ot0EIB/yGtLI2EsGoY6F7XKklfMxYQyvtL+gT/UCqkMzO0BVFHLDlzvFqQzurYahxv9Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.20.0", + "@typescript-eslint/types": "8.25.0", + "@typescript-eslint/visitor-keys": "8.25.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.25.0.tgz", + "integrity": "sha512-6PPeiKIGbgStEyt4NNXa2ru5pMzQ8OYKO1hX1z53HMomrmiSB+R5FmChgQAP1ro8jMtNawz+TRQo/cSXrauTpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.25.0", + "@typescript-eslint/visitor-keys": "8.25.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.25.0.tgz", + "integrity": "sha512-+vUe0Zb4tkNgznQwicsvLUJgZIRs6ITeWSCclX1q85pR1iOiaj+4uZJIUp//Z27QWu5Cseiw3O3AR8hVpax7Aw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.25.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.25.0.tgz", + "integrity": "sha512-kCYXKAum9CecGVHGij7muybDfTS2sD3t0L4bJsEZLkyrXUImiCTq1M3LG2SRtOhiHFwMR9wAFplpT6XHYjTkwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.25.0", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -2690,9 +2862,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.3.tgz", - "integrity": "sha512-uVbJ/xhImdNtzPnLyxCZJMTeTIYdgcC2nWtBBBpR1H6z0w8m7D+9/zrDIx2nNxgMg9r+X8+RY2qVpUDeW2b3nw==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.7.tgz", + "integrity": "sha512-Av8WgBJLTrfLOer0uy3CxjlVuWK4CzcLBndW1Nm2vI+3hZ2ozHututkfc7Blu1u6waeQ7J8gzPK/AsBRnWA5mQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2713,8 +2885,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.0.3", - "vitest": "3.0.3" + "@vitest/browser": "3.0.7", + "vitest": "3.0.7" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2741,15 +2913,15 @@ } }, "node_modules/@vitest/expect": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.3.tgz", - "integrity": "sha512-SbRCHU4qr91xguu+dH3RUdI5dC86zm8aZWydbp961aIR7G8OYNN6ZiayFuf9WAngRbFOfdrLHCGgXTj3GtoMRQ==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.7.tgz", + "integrity": "sha512-QP25f+YJhzPfHrHfYHtvRn+uvkCFCqFtW9CktfBxmB+25QqWsx7VB2As6f4GmwllHLDhXNHvqedwhvMmSnNmjw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.0.3", - "@vitest/utils": "3.0.3", - "chai": "^5.1.2", + "@vitest/spy": "3.0.7", + "@vitest/utils": "3.0.7", + "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, "funding": { @@ -2757,13 +2929,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.3.tgz", - "integrity": "sha512-XT2XBc4AN9UdaxJAeIlcSZ0ILi/GzmG5G8XSly4gaiqIvPV3HMTSIDZWJVX6QRJ0PX1m+W8Cy0K9ByXNb/bPIA==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.7.tgz", + "integrity": "sha512-qui+3BLz9Eonx4EAuR/i+QlCX6AUZ35taDQgwGkK/Tw6/WgwodSrjN1X2xf69IA/643ZX5zNKIn2svvtZDrs4w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.0.3", + "@vitest/spy": "3.0.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -2784,9 +2956,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.3.tgz", - "integrity": "sha512-gCrM9F7STYdsDoNjGgYXKPq4SkSxwwIU5nkaQvdUxiQ0EcNlez+PdKOVIsUJvh9P9IeIFmjn4IIREWblOBpP2Q==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.7.tgz", + "integrity": "sha512-CiRY0BViD/V8uwuEzz9Yapyao+M9M008/9oMOSQydwbwb+CMokEq3XVaF3XK/VWaOK0Jm9z7ENhybg70Gtxsmg==", "dev": true, "license": "MIT", "dependencies": { @@ -2797,38 +2969,38 @@ } }, "node_modules/@vitest/runner": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.3.tgz", - "integrity": "sha512-Rgi2kOAk5ZxWZlwPguRJFOBmWs6uvvyAAR9k3MvjRvYrG7xYvKChZcmnnpJCS98311CBDMqsW9MzzRFsj2gX3g==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.7.tgz", + "integrity": "sha512-WeEl38Z0S2ZcuRTeyYqaZtm4e26tq6ZFqh5y8YD9YxfWuu0OFiGFUbnxNynwLjNRHPsXyee2M9tV7YxOTPZl2g==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.0.3", - "pathe": "^2.0.1" + "@vitest/utils": "3.0.7", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.3.tgz", - "integrity": "sha512-kNRcHlI4txBGztuJfPEJ68VezlPAXLRT1u5UCx219TU3kOG2DplNxhWLwDf2h6emwmTPogzLnGVwP6epDaJN6Q==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.7.tgz", + "integrity": "sha512-eqTUryJWQN0Rtf5yqCGTQWsCFOQe4eNz5Twsu21xYEcnFJtMU5XvmG0vgebhdLlrHQTSq5p8vWHJIeJQV8ovsA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.0.3", + "@vitest/pretty-format": "3.0.7", "magic-string": "^0.30.17", - "pathe": "^2.0.1" + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/spy": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.3.tgz", - "integrity": "sha512-7/dgux8ZBbF7lEIKNnEqQlyRaER9nkAL9eTmdKJkDO3hS8p59ATGwKOCUDHcBLKr7h/oi/6hP+7djQk8049T2A==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.7.tgz", + "integrity": "sha512-4T4WcsibB0B6hrKdAZTM37ekuyFZt2cGbEGd2+L0P8ov15J1/HUsUaqkXEQPNAWr4BtPPe1gI+FYfMHhEKfR8w==", "dev": true, "license": "MIT", "dependencies": { @@ -2839,14 +3011,14 @@ } }, "node_modules/@vitest/utils": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.3.tgz", - "integrity": "sha512-f+s8CvyzPtMFY1eZKkIHGhPsQgYo5qCm6O8KZoim9qm1/jT64qBgGpO5tHscNH6BzRHM+edLNOP+3vO8+8pE/A==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.7.tgz", + "integrity": "sha512-xePVpCRfooFX3rANQjwoditoXgWb1MaFbzmGuPP59MK6i13mrnDw/yEIyJudLeW6/38mCNcwCiJIGmpDPibAIg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.0.3", - "loupe": "^3.1.2", + "@vitest/pretty-format": "3.0.7", + "loupe": "^3.1.3", "tinyrainbow": "^2.0.0" }, "funding": { @@ -2854,9 +3026,9 @@ } }, "node_modules/@zoom-image/core": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@zoom-image/core/-/core-0.39.0.tgz", - "integrity": "sha512-JD6UghIOvfdRdI5FCFQRtvaJGht2gIpkzFp+5NrcwKXbHQwSfl00VQ9JQ0TYbaeHa6tc+dxgepYgJukCtrPVgg==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@zoom-image/core/-/core-0.40.0.tgz", + "integrity": "sha512-NsXhw7QCXu7390rs7AlcB3d8Vx6HnEB9gFHpF9yf5IfhB8wrv37IgIL1FC4QW+1id5FeGv2o62XyPL6vMz3EfA==", "license": "MIT", "dependencies": { "@namnode/store": "^0.1.0" @@ -2867,12 +3039,12 @@ } }, "node_modules/@zoom-image/svelte": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@zoom-image/svelte/-/svelte-0.3.0.tgz", - "integrity": "sha512-0dfAPgpGRm+j6d3fn044swV7r821l2ZFJZmR0WqUATUUaPZ3GbDkDyrVuZGmP7s4QAk/Nvs1F3+cBhcMWt9Zfw==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@zoom-image/svelte/-/svelte-0.3.1.tgz", + "integrity": "sha512-ZJj5/Q11jzcpjItqPJpfvU/XpcWkOehvTcIBZzenGfUWprJ6o8qSesuUAum1WEpv2vbTwUlybe5h3Rim3/vIGA==", "license": "MIT", "dependencies": { - "@zoom-image/core": "0.39.0" + "@zoom-image/core": "0.40.0" }, "funding": { "type": "github", @@ -2882,6 +3054,21 @@ "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0" } }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true + }, "node_modules/acorn": { "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", @@ -2894,6 +3081,17 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-globals": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", + "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "acorn": "^8.1.0", + "acorn-walk": "^8.0.2" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -2912,6 +3110,19 @@ "acorn": ">=8.9.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "license": "MIT", + "optional": true, + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", @@ -2979,6 +3190,28 @@ "node": ">= 8" } }, + "node_modules/aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -3029,9 +3262,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "optional": true, - "peer": true + "optional": true }, "node_modules/autoprefixer": { "version": "10.4.20", @@ -3121,7 +3352,7 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, + "devOptional": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3140,9 +3371,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.3.tgz", - "integrity": "sha512-1CPmv8iobE2fyRMV97dAcMVegvvWKxmq94hkLiAkUGwKVTyDLw33K+ZxiFrREKmmps4rIw6grcCFCnTMSZ/YiA==", + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", + "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", "dev": true, "funding": [ { @@ -3179,12 +3410,13 @@ "dev": true }, "node_modules/builtin-modules": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", - "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-4.0.0.tgz", + "integrity": "sha512-p1n8zyCkt1BVrKNFymOHjcDSAl7oq/gUvfgULv2EblgpPVQlQr9yHnWjg9IJ2MhfwPqiYqMMrr01OY7yQoK2yA==", "dev": true, + "license": "MIT", "engines": { - "node": ">=6" + "node": ">=18.20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -3226,6 +3458,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -3255,10 +3496,26 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz", + "integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.0", + "nan": "^2.17.0", + "simple-get": "^3.0.3" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/chai": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz", - "integrity": "sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", + "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", "dev": true, "license": "MIT", "dependencies": { @@ -3331,10 +3588,20 @@ "node": ">= 6" } }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, "node_modules/ci-info": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.0.0.tgz", - "integrity": "sha512-TdHqgGf9odd8SXNuxtUBVx8Nv+qZOejE6qyqiy5NtbYYQOeFa6zmHkxlPzmaLxWWHsU6nJmB7AETdVPi+2NBUg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.1.0.tgz", + "integrity": "sha512-HutrvTNsF48wnxkzERIXOe5/mlcfFcbfCmwcg6CJnizbSue78AbDt+1cgl26zwn61WFxhcPykPfZrbqjGmBb4A==", "dev": true, "funding": [ { @@ -3342,6 +3609,7 @@ "url": "https://github.com/sponsors/sibiraj-s" } ], + "license": "MIT", "engines": { "node": ">=8" } @@ -3448,6 +3716,16 @@ "simple-swizzle": "^0.2.2" } }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, "node_modules/color/node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3470,9 +3748,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "optional": true, - "peer": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -3492,7 +3768,14 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "devOptional": true + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true }, "node_modules/cookie": { "version": "0.6.0", @@ -3504,13 +3787,13 @@ } }, "node_modules/core-js-compat": { - "version": "3.39.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.39.0.tgz", - "integrity": "sha512-VgEUx3VwlExr5no0tXlBt+silBvhTryPwCXRI2Id1PN8WTKu7MreethvddqOubrYxkFdv/RnYrqlv1sFNAUelw==", + "version": "3.41.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.41.0.tgz", + "integrity": "sha512-RFsU9LySVue9RTwdDVX/T0e2Y6jRYWXERKElIjpuEOEnxaXffI0X7RUwVzfYLfzuLXSNJDYoRYUAmRUcyln20A==", "dev": true, "license": "MIT", "dependencies": { - "browserslist": "^4.24.2" + "browserslist": "^4.24.4" }, "funding": { "type": "opencollective", @@ -3555,6 +3838,13 @@ "node": ">=4" } }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "license": "MIT", + "optional": true + }, "node_modules/cssstyle": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz", @@ -3635,11 +3925,33 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==" }, + "node_modules/decompress-response": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", + "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==", + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -3677,13 +3989,18 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "optional": true, - "peer": true, "engines": { "node": ">=0.4.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3696,7 +4013,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", - "dev": true, + "devOptional": true, "engines": { "node": ">=8" } @@ -3713,6 +4030,12 @@ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -3729,6 +4052,20 @@ "resolved": "https://registry.npmjs.org/dom-to-image/-/dom-to-image-2.6.0.tgz", "integrity": "sha512-Dt0QdaHmLpjURjU7Tnu3AgYSF2LuOmksSGsUcE6ItvJoCWTBEmiMXcqBdNSAm9+QbbwD7JMoVsuuKX6ZVQv1qA==" }, + "node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "deprecated": "Use your platform's native DOMException instead", + "license": "MIT", + "optional": true, + "dependencies": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/dotenv": { "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", @@ -3765,16 +4102,16 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/engine.io-client": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.4.tgz", - "integrity": "sha512-GeZeeRjpD2qf49cZQ0Wvh/8NJNfeXkXXcoGh+F77oEAgo9gUHwT1fCRxSNU+YEEaysOJTnsFHmM5oAcPy4ntvQ==", + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", + "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==", "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1", - "xmlhttprequest-ssl": "~2.0.0" + "xmlhttprequest-ssl": "~2.1.1" } }, "node_modules/engine.io-parser": { @@ -3790,9 +4127,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, "optional": true, - "peer": true, "engines": { "node": ">=0.12" }, @@ -3800,15 +4135,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, "node_modules/es-module-lexer": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", @@ -3865,9 +4191,9 @@ } }, "node_modules/esbuild": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", - "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", + "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3878,31 +4204,31 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.24.2", - "@esbuild/android-arm": "0.24.2", - "@esbuild/android-arm64": "0.24.2", - "@esbuild/android-x64": "0.24.2", - "@esbuild/darwin-arm64": "0.24.2", - "@esbuild/darwin-x64": "0.24.2", - "@esbuild/freebsd-arm64": "0.24.2", - "@esbuild/freebsd-x64": "0.24.2", - "@esbuild/linux-arm": "0.24.2", - "@esbuild/linux-arm64": "0.24.2", - "@esbuild/linux-ia32": "0.24.2", - "@esbuild/linux-loong64": "0.24.2", - "@esbuild/linux-mips64el": "0.24.2", - "@esbuild/linux-ppc64": "0.24.2", - "@esbuild/linux-riscv64": "0.24.2", - "@esbuild/linux-s390x": "0.24.2", - "@esbuild/linux-x64": "0.24.2", - "@esbuild/netbsd-arm64": "0.24.2", - "@esbuild/netbsd-x64": "0.24.2", - "@esbuild/openbsd-arm64": "0.24.2", - "@esbuild/openbsd-x64": "0.24.2", - "@esbuild/sunos-x64": "0.24.2", - "@esbuild/win32-arm64": "0.24.2", - "@esbuild/win32-ia32": "0.24.2", - "@esbuild/win32-x64": "0.24.2" + "@esbuild/aix-ppc64": "0.25.0", + "@esbuild/android-arm": "0.25.0", + "@esbuild/android-arm64": "0.25.0", + "@esbuild/android-x64": "0.25.0", + "@esbuild/darwin-arm64": "0.25.0", + "@esbuild/darwin-x64": "0.25.0", + "@esbuild/freebsd-arm64": "0.25.0", + "@esbuild/freebsd-x64": "0.25.0", + "@esbuild/linux-arm": "0.25.0", + "@esbuild/linux-arm64": "0.25.0", + "@esbuild/linux-ia32": "0.25.0", + "@esbuild/linux-loong64": "0.25.0", + "@esbuild/linux-mips64el": "0.25.0", + "@esbuild/linux-ppc64": "0.25.0", + "@esbuild/linux-riscv64": "0.25.0", + "@esbuild/linux-s390x": "0.25.0", + "@esbuild/linux-x64": "0.25.0", + "@esbuild/netbsd-arm64": "0.25.0", + "@esbuild/netbsd-x64": "0.25.0", + "@esbuild/openbsd-arm64": "0.25.0", + "@esbuild/openbsd-x64": "0.25.0", + "@esbuild/sunos-x64": "0.25.0", + "@esbuild/win32-arm64": "0.25.0", + "@esbuild/win32-ia32": "0.25.0", + "@esbuild/win32-x64": "0.25.0" } }, "node_modules/escalade": { @@ -3924,23 +4250,45 @@ "node": ">=0.8.0" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, "node_modules/eslint": { - "version": "9.18.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.18.0.tgz", - "integrity": "sha512-+waTfRWQlSbpt3KWE+CjrPPYnbq9kfZIYUqapc0uBXyjTp8aYXZDsUH16m39Ryq3NjAVP4tjuF7KaukeqoCoaA==", + "version": "9.21.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.21.0.tgz", + "integrity": "sha512-KjeihdFqTPhOMXTt7StsDxriV4n66ueuF/jfPNC3j/lduHwr/ijDwJMsF+wyMJethgiKi5wniIE243vi07d3pg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.19.0", - "@eslint/core": "^0.10.0", - "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "9.18.0", - "@eslint/plugin-kit": "^0.2.5", + "@eslint/config-array": "^0.19.2", + "@eslint/core": "^0.12.0", + "@eslint/eslintrc": "^3.3.0", + "@eslint/js": "9.21.0", + "@eslint/plugin-kit": "^0.2.7", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.1", + "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", @@ -3985,9 +4333,9 @@ } }, "node_modules/eslint-compat-utils": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/eslint-compat-utils/-/eslint-compat-utils-0.5.1.tgz", - "integrity": "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==", + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/eslint-compat-utils/-/eslint-compat-utils-0.6.4.tgz", + "integrity": "sha512-/u+GQt8NMfXO8w17QendT4gvO5acfxQsAKirAt0LVxDnr2N8YLCVbregaNc/Yhp7NM128DwCaRvr8PLDfeNkQw==", "dev": true, "license": "MIT", "dependencies": { @@ -4001,9 +4349,9 @@ } }, "node_modules/eslint-config-prettier": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.0.1.tgz", - "integrity": "sha512-lZBts941cyJyeaooiKxAtzoPHTN+GbQTJFAIdQbRhA4/8whaAraEh47Whw/ZFfrjNSnlAxqfm9i0XVAEkULjCw==", + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.0.2.tgz", + "integrity": "sha512-1105/17ZIMjmCOJOPNfVdbXafLCLj3hPmkmB7dLgt7XsQ/zkxSuDerE/xgO3RxoHysR1N1whmquY0lSn2O0VLg==", "dev": true, "license": "MIT", "bin": { @@ -4014,32 +4362,31 @@ } }, "node_modules/eslint-plugin-svelte": { - "version": "2.46.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.46.1.tgz", - "integrity": "sha512-7xYr2o4NID/f9OEYMqxsEQsCsj4KaMy4q5sANaKkAb6/QeCjYFxRmDm2S3YC3A3pl1kyPZ/syOx/i7LcWYSbIw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.0.2.tgz", + "integrity": "sha512-+0QglmWNryvXXxRQKzLF3i+AreTsueCw7PBb0nGVBq+F9HoYqAjQeJ/9N6vFAtjMjK3wgsETrLVyBKPdeufN6Q==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@jridgewell/sourcemap-codec": "^1.4.15", - "eslint-compat-utils": "^0.5.1", + "@eslint-community/eslint-utils": "^4.4.1", + "@jridgewell/sourcemap-codec": "^1.5.0", + "eslint-compat-utils": "^0.6.4", "esutils": "^2.0.3", "known-css-properties": "^0.35.0", - "postcss": "^8.4.38", + "postcss": "^8.4.49", "postcss-load-config": "^3.1.4", - "postcss-safe-parser": "^6.0.0", - "postcss-selector-parser": "^6.1.0", - "semver": "^7.6.2", - "svelte-eslint-parser": "^0.43.0" + "postcss-safe-parser": "^7.0.0", + "semver": "^7.6.3", + "svelte-eslint-parser": "^1.0.0" }, "engines": { - "node": "^14.17.0 || >=16.0.0" + "node": "^18.20.4 || ^20.18.0 || >=22.10.0" }, "funding": { "url": "https://github.com/sponsors/ota-meshi" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0-0 || ^9.0.0-0", + "eslint": "^8.57.1 || ^9.0.0", "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "peerDependenciesMeta": { @@ -4049,28 +4396,28 @@ } }, "node_modules/eslint-plugin-unicorn": { - "version": "56.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-56.0.1.tgz", - "integrity": "sha512-FwVV0Uwf8XPfVnKSGpMg7NtlZh0G0gBarCaFcMUOoqPxXryxdYxTRRv4kH6B9TFCVIrjRXG+emcxIk2ayZilog==", + "version": "57.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-57.0.0.tgz", + "integrity": "sha512-zUYYa6zfNdTeG9BISWDlcLmz16c+2Ck2o5ZDHh0UzXJz3DEP7xjmlVDTzbyV0W+XksgZ0q37WEWzN2D2Ze+g9Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.24.7", - "@eslint-community/eslint-utils": "^4.4.0", - "ci-info": "^4.0.0", + "@babel/helper-validator-identifier": "^7.25.9", + "@eslint-community/eslint-utils": "^4.4.1", + "ci-info": "^4.1.0", "clean-regexp": "^1.0.0", - "core-js-compat": "^3.38.1", + "core-js-compat": "^3.40.0", "esquery": "^1.6.0", - "globals": "^15.9.0", - "indent-string": "^4.0.0", - "is-builtin-module": "^3.2.1", - "jsesc": "^3.0.2", + "globals": "^15.15.0", + "indent-string": "^5.0.0", + "is-builtin-module": "^4.0.0", + "jsesc": "^3.1.0", "pluralize": "^8.0.0", - "read-pkg-up": "^7.0.1", + "read-package-up": "^11.0.0", "regexp-tree": "^0.1.27", - "regjsparser": "^0.10.0", - "semver": "^7.6.3", - "strip-indent": "^3.0.0" + "regjsparser": "^0.12.0", + "semver": "^7.7.1", + "strip-indent": "^4.0.0" }, "engines": { "node": ">=18.18" @@ -4079,33 +4426,63 @@ "url": "https://github.com/sindresorhus/eslint-plugin-unicorn?sponsor=1" }, "peerDependencies": { - "eslint": ">=8.56.0" + "eslint": ">=9.20.0" } }, - "node_modules/eslint-plugin-unicorn/node_modules/jsesc": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", - "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "node_modules/eslint-plugin-unicorn/node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", "dev": true, "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/strip-indent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.0.0.tgz", + "integrity": "sha512-mnVSV2l+Zv6BLpSD/8V87CW/y9EmmbYzGCIavsnsI6/nwn26DwffM/yztm30Z/I2DY9wdS3vXVCMnHDgZaVNoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.1" }, "engines": { - "node": ">=6" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz", + "integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -4124,9 +4501,9 @@ } }, "node_modules/eslint/node_modules/@humanwhocodes/retry": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz", - "integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", + "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -4203,23 +4580,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/eslint-scope": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz", - "integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", @@ -4233,24 +4593,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.14.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/eslint/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -4295,22 +4637,50 @@ } }, "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", + "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.9.0", + "acorn": "^8.14.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" + "eslint-visitor-keys": "^4.2.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", @@ -4349,7 +4719,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, + "devOptional": true, "engines": { "node": ">=4.0" } @@ -4368,7 +4738,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, + "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -4411,6 +4781,228 @@ "node": ">=0.10.0" } }, + "node_modules/fabric": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/fabric/-/fabric-6.6.1.tgz", + "integrity": "sha512-QrQkx6I7daFL/WdkrE8VOEiAr/ffLK36NQ0t/vNZt8P7QIXPpjT4HegjOatUW1G6vYlulX4pI1P/5NeqIgsDig==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "canvas": "^2.11.2", + "jsdom": "^20.0.1" + } + }, + "node_modules/fabric/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/fabric/node_modules/cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "license": "MIT", + "optional": true, + "dependencies": { + "cssom": "~0.3.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fabric/node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "license": "MIT", + "optional": true + }, + "node_modules/fabric/node_modules/data-urls": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", + "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fabric/node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fabric/node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fabric/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fabric/node_modules/jsdom": { + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", + "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "abab": "^2.0.6", + "acorn": "^8.8.1", + "acorn-globals": "^7.0.0", + "cssom": "^0.5.0", + "cssstyle": "^2.3.0", + "data-urls": "^3.0.2", + "decimal.js": "^10.4.2", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.2", + "parse5": "^7.1.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0", + "ws": "^8.11.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/fabric/node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "license": "MIT", + "optional": true, + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fabric/node_modules/w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "license": "MIT", + "optional": true, + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/fabric/node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fabric/node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/fabric/node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fabric/node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=12" + } + }, "node_modules/factory.ts": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/factory.ts/-/factory.ts-1.4.2.tgz", @@ -4524,6 +5116,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-up-simple": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -4564,9 +5169,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, "optional": true, - "peer": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -4589,6 +5192,39 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC", + "optional": true + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4607,6 +5243,35 @@ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/gauge/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true + }, "node_modules/geojson-vt": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", @@ -4616,7 +5281,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "engines": { "node": "6.* || 8.* || >= 10.*" } @@ -4725,9 +5389,9 @@ } }, "node_modules/globals": { - "version": "15.14.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.14.0.tgz", - "integrity": "sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==", + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.0.0.tgz", + "integrity": "sha512-iInW14XItCXET01CQFqudPOWP2jYMl7T+QRQT+UNcR/iQncN/F0UNpgd76iFkBPgNQb4+X3LV9tLJYzwh+Gl3A==", "dev": true, "license": "MIT", "engines": { @@ -4800,11 +5464,25 @@ "node": ">=4" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true + }, "node_modules/hosted-git-info": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", - "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", - "dev": true + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", @@ -4860,9 +5538,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "optional": true, - "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -4964,6 +5640,38 @@ "node": ">=8" } }, + "node_modules/index-to-position": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-0.1.2.tgz", + "integrity": "sha512-MWDKS3AS1bGCHLBA2VLImJz42f7bJh8wQsTGCzI3j519/CASStoDONUBVz2I/VID0MpiX3SGSnbOD2xUalbE5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC", + "optional": true + }, "node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", @@ -4984,23 +5692,17 @@ } }, "node_modules/intl-messageformat": { - "version": "10.7.12", - "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.12.tgz", - "integrity": "sha512-4HBsPDJ61jZwNikauvm0mcLvs1AfCBbihiqOX2AGs1MX7SA1H0SNKJRSWxpZpToGoNzvoYLsJJ2pURkbEDg+Dw==", + "version": "10.7.15", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.15.tgz", + "integrity": "sha512-LRyExsEsefQSBjU2p47oAheoKz+EOJxSLDdjOaEjdriajfHsMXOmV/EhMvYSg9bAgCUHasuAC+mcUBe/95PfIg==", "license": "BSD-3-Clause", "dependencies": { - "@formatjs/ecma402-abstract": "2.3.2", + "@formatjs/ecma402-abstract": "2.3.3", "@formatjs/fast-memoize": "2.2.6", - "@formatjs/icu-messageformat-parser": "2.10.0", + "@formatjs/icu-messageformat-parser": "2.11.1", "tslib": "2" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -5013,15 +5715,16 @@ } }, "node_modules/is-builtin-module": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz", - "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-4.0.0.tgz", + "integrity": "sha512-rWP3AMAalQSesXO8gleROyL2iKU73SX5Er66losQn9rWOWL4Gef0a/xOEOVqjWGMuR2vHG3FJ8UUmT700O8oFg==", "dev": true, + "license": "MIT", "dependencies": { - "builtin-modules": "^3.3.0" + "builtin-modules": "^4.0.0" }, "engines": { - "node": ">=6" + "node": ">=18.20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -5112,9 +5815,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "optional": true, - "peer": true + "optional": true }, "node_modules/is-promise": { "version": "2.2.2", @@ -5309,6 +6010,19 @@ } } }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -5316,12 +6030,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -5458,12 +6166,18 @@ "dev": true }, "node_modules/loupe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", - "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", + "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/lru-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz", @@ -5681,9 +6395,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "optional": true, - "peer": true, "engines": { "node": ">= 0.6" } @@ -5692,9 +6404,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "optional": true, - "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -5702,6 +6412,19 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", + "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -5715,7 +6438,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, + "devOptional": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -5739,6 +6462,46 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "optional": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -5777,6 +6540,13 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nan": { + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.0.tgz", + "integrity": "sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.8", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", @@ -5811,6 +6581,52 @@ "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "optional": true + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", @@ -5818,25 +6634,35 @@ "dev": true, "license": "MIT" }, - "node_modules/normalize-package-data": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", - "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", - "dev": true, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "optional": true, "dependencies": { - "hosted-git-info": "^2.1.4", - "resolve": "^1.10.0", - "semver": "2 || 3 || 4 || 5", - "validate-npm-package-license": "^3.0.1" + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" } }, - "node_modules/normalize-package-data/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", "dev": true, - "bin": { - "semver": "bin/semver" + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" } }, "node_modules/normalize-path": { @@ -5856,13 +6682,25 @@ "node": ">=0.10.0" } }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, "node_modules/nwsapi": { "version": "2.2.7", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.7.tgz", "integrity": "sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==", - "dev": true, - "optional": true, - "peer": true + "optional": true }, "node_modules/object-assign": { "version": "4.1.1", @@ -5880,6 +6718,16 @@ "node": ">= 6" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, "node_modules/open": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", @@ -5948,7 +6796,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -5971,18 +6819,18 @@ } }, "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.1.0.tgz", + "integrity": "sha512-rum1bPifK5SSar35Z6EKZuYPJx85pkNaFrxBK3mwdfSJ1/WKbYrjoW/zTPSjRRamfmVX1ACBIdFAO0VRErW/EA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" + "@babel/code-frame": "^7.22.13", + "index-to-position": "^0.1.2", + "type-fest": "^4.7.1" }, "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -5992,9 +6840,7 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", - "dev": true, "optional": true, - "peer": true, "dependencies": { "entities": "^4.4.0" }, @@ -6006,11 +6852,20 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "engines": { "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -6039,15 +6894,10 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" - }, "node_modules/pathe": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.2.tgz", - "integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, @@ -6116,18 +6966,27 @@ } }, "node_modules/pmtiles": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-3.0.3.tgz", - "integrity": "sha512-tj4l3HHJd6/qf9VefzlPK2eYEQgbf+4uXPzNlrj3k7hHvLtibYSxfp51TF6ALt4YezM8MCdiOminnHvdAyqyGg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.3.0.tgz", + "integrity": "sha512-wnzQeSiYT/MyO63o7AVxwt7+uKqU0QUy2lHrivM7GvecNy0m1A4voVyGey7bujnEW5Hn+ZzLdvHPoFaqrOzbPA==", + "license": "BSD-3-Clause", "dependencies": { - "@types/leaflet": "^1.9.8", - "fflate": "^0.8.0" + "fflate": "^0.8.2" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" } }, "node_modules/postcss": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz", - "integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==", + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", "funding": [ { "type": "opencollective", @@ -6251,19 +7110,30 @@ } }, "node_modules/postcss-safe-parser": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-6.0.0.tgz", - "integrity": "sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", + "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", "engines": { - "node": ">=12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" + "node": ">=18.0" }, "peerDependencies": { - "postcss": "^8.3.3" + "postcss": "^8.4.31" } }, "node_modules/postcss-scss": { @@ -6326,9 +7196,9 @@ } }, "node_modules/prettier": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", - "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.2.tgz", + "integrity": "sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg==", "dev": true, "license": "MIT", "bin": { @@ -6417,15 +7287,181 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", - "dev": true, - "optional": true, - "peer": true + "optional": true }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, + "devOptional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/qrcode/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, "engines": { "node": ">=6" } @@ -6434,9 +7470,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true, - "optional": true, - "peer": true + "optional": true }, "node_modules/queue-microtask": { "version": "1.2.3", @@ -6476,106 +7510,57 @@ "pify": "^2.3.0" } }, + "node_modules/read-package-up": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", + "integrity": "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up-simple": "^1.0.0", + "read-pkg": "^9.0.0", + "type-fest": "^4.6.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/read-pkg": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", - "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", + "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", "dev": true, + "license": "MIT", "dependencies": { - "@types/normalize-package-data": "^2.4.0", - "normalize-package-data": "^2.5.0", - "parse-json": "^5.0.0", - "type-fest": "^0.6.0" + "@types/normalize-package-data": "^2.4.3", + "normalize-package-data": "^6.0.0", + "parse-json": "^8.0.0", + "type-fest": "^4.6.0", + "unicorn-magic": "^0.1.0" }, "engines": { - "node": ">=8" - } - }, - "node_modules/read-pkg-up": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", - "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", - "dev": true, - "dependencies": { - "find-up": "^4.1.0", - "read-pkg": "^5.2.0", - "type-fest": "^0.8.1" - }, - "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-pkg-up/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" }, "engines": { - "node": ">=8" - } - }, - "node_modules/read-pkg-up/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/read-pkg-up/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg-up/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/read-pkg-up/node_modules/type-fest": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/read-pkg/node_modules/type-fest": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", - "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", - "dev": true, - "engines": { - "node": ">=8" + "node": ">= 6" } }, "node_modules/readdirp": { @@ -6618,42 +7603,50 @@ } }, "node_modules/regjsparser": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.10.0.tgz", - "integrity": "sha512-qx+xQGZVsy55CH0a1hiVwHmqjLryfh7wQyF5HO07XJ9f7dQMY/gPQHhlyDkIzJKC+x2fUCpCcUODUUUFrm7SHA==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", + "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "jsesc": "~0.5.0" + "jsesc": "~3.0.2" }, "bin": { "regjsparser": "bin/parser" } }, "node_modules/regjsparser/node_modules/jsesc": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", - "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", "dev": true, + "license": "MIT", "bin": { "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" } }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, - "optional": true, - "peer": true + "optional": true }, "node_modules/resolve": { "version": "1.22.8", @@ -6689,10 +7682,49 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rollup": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.28.1.tgz", - "integrity": "sha512-61fXYl/qNVinKmGSTHAZ6Yy8I3YIJC/r2m9feHo6SwVAVcLT5MPwOUFe7EuURA/4m0NR8lXG4BBXuo/IZEsjMg==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.6.tgz", + "integrity": "sha512-wc2cBWqJgkU3Iz5oztRkQbfVkbxoz5EhnCGOrnJvnLnQ7O0WhQUYyv18qQI79O8L7DdHrrlJNeCHd4VGpnaXKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6706,25 +7738,25 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.28.1", - "@rollup/rollup-android-arm64": "4.28.1", - "@rollup/rollup-darwin-arm64": "4.28.1", - "@rollup/rollup-darwin-x64": "4.28.1", - "@rollup/rollup-freebsd-arm64": "4.28.1", - "@rollup/rollup-freebsd-x64": "4.28.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.28.1", - "@rollup/rollup-linux-arm-musleabihf": "4.28.1", - "@rollup/rollup-linux-arm64-gnu": "4.28.1", - "@rollup/rollup-linux-arm64-musl": "4.28.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.28.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.28.1", - "@rollup/rollup-linux-riscv64-gnu": "4.28.1", - "@rollup/rollup-linux-s390x-gnu": "4.28.1", - "@rollup/rollup-linux-x64-gnu": "4.28.1", - "@rollup/rollup-linux-x64-musl": "4.28.1", - "@rollup/rollup-win32-arm64-msvc": "4.28.1", - "@rollup/rollup-win32-ia32-msvc": "4.28.1", - "@rollup/rollup-win32-x64-msvc": "4.28.1", + "@rollup/rollup-android-arm-eabi": "4.34.6", + "@rollup/rollup-android-arm64": "4.34.6", + "@rollup/rollup-darwin-arm64": "4.34.6", + "@rollup/rollup-darwin-x64": "4.34.6", + "@rollup/rollup-freebsd-arm64": "4.34.6", + "@rollup/rollup-freebsd-x64": "4.34.6", + "@rollup/rollup-linux-arm-gnueabihf": "4.34.6", + "@rollup/rollup-linux-arm-musleabihf": "4.34.6", + "@rollup/rollup-linux-arm64-gnu": "4.34.6", + "@rollup/rollup-linux-arm64-musl": "4.34.6", + "@rollup/rollup-linux-loongarch64-gnu": "4.34.6", + "@rollup/rollup-linux-powerpc64le-gnu": "4.34.6", + "@rollup/rollup-linux-riscv64-gnu": "4.34.6", + "@rollup/rollup-linux-s390x-gnu": "4.34.6", + "@rollup/rollup-linux-x64-gnu": "4.34.6", + "@rollup/rollup-linux-x64-musl": "4.34.6", + "@rollup/rollup-win32-arm64-msvc": "4.34.6", + "@rollup/rollup-win32-ia32-msvc": "4.34.6", + "@rollup/rollup-win32-x64-msvc": "4.34.6", "fsevents": "~2.3.2" } }, @@ -6842,21 +7874,38 @@ "node": ">=6" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "optional": true, - "peer": true + "optional": true }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, "optional": true, - "peer": true, "dependencies": { "xmlchars": "^2.2.0" }, @@ -6865,10 +7914,10 @@ } }, "node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -6877,6 +7926,12 @@ "node": ">=10" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", @@ -6985,6 +8040,39 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz", + "integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==", + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^4.2.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", @@ -7016,14 +8104,14 @@ } }, "node_modules/socket.io-client": { - "version": "4.7.5", - "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.5.tgz", - "integrity": "sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==", + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", + "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.2", - "engine.io-client": "~6.5.2", + "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" }, "engines": { @@ -7106,32 +8194,36 @@ "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", "dev": true, + "license": "Apache-2.0", "dependencies": { "spdx-expression-parse": "^3.0.0", "spdx-license-ids": "^3.0.0" } }, "node_modules/spdx-exceptions": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.4.0.tgz", - "integrity": "sha512-hcjppoJ68fhxA/cjbN4T8N6uCUejN8yFw69ttpqtBeCbF3u13n7mb31NB9jKwGTTWWnt9IbRA/mf1FprYS8wfw==", - "dev": true + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" }, "node_modules/spdx-expression-parse": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", "dev": true, + "license": "MIT", "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "node_modules/spdx-license-ids": { - "version": "3.0.16", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz", - "integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==", - "dev": true + "version": "3.0.21", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz", + "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==", + "dev": true, + "license": "CC0-1.0" }, "node_modules/split-string": { "version": "3.1.0", @@ -7180,6 +8272,16 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -7317,9 +8419,9 @@ } }, "node_modules/svelte": { - "version": "5.19.0", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.19.0.tgz", - "integrity": "sha512-qvd2GvvYnJxS/MteQKFSMyq8cQrAAut28QZ39ySv9k3ggmhw4Au4Rfcsqva74i0xMys//OhbhVCNfXPrDzL/Bg==", + "version": "5.20.5", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.20.5.tgz", + "integrity": "sha512-dpu2lTPVsAAgZFKpF7A9741sBCdXGogfxFU4aQeVgun7GVNCSVheTzj0FsT7g9OsLhBaMX4lKLwVIvmzQGytmQ==", "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.3.0", @@ -7426,20 +8528,21 @@ } }, "node_modules/svelte-eslint-parser": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", - "integrity": "sha512-GpU52uPKKcVnh8tKN5P4UZpJ/fUDndmq7wfsvoVXsyP+aY0anol7Yqo01fyrlaWGMFfm4av5DyrjlaXdLRJvGA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.0.0.tgz", + "integrity": "sha512-diZzpeeFhAxormeIhmRS4vXx98GG6T7Dq5y1a6qffqs/5MBrBqqDg8bj88iEohp6bvhU4MIABJmOTa0gXWcbSQ==", "dev": true, "license": "MIT", "dependencies": { - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "postcss": "^8.4.39", - "postcss-scss": "^4.0.9" + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.0.0", + "espree": "^10.0.0", + "postcss": "^8.4.49", + "postcss-scss": "^4.0.9", + "postcss-selector-parser": "^7.0.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.20.4 || ^20.18.0 || >=22.10.0" }, "funding": { "url": "https://github.com/sponsors/ota-meshi" @@ -7453,6 +8556,33 @@ } } }, + "node_modules/svelte-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte-eslint-parser/node_modules/postcss-selector-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/svelte-gestures": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/svelte-gestures/-/svelte-gestures-5.1.3.tgz", @@ -7937,6 +9067,16 @@ } } }, + "node_modules/svelte-maplibre/node_modules/pmtiles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-3.2.1.tgz", + "integrity": "sha512-3R4fBwwoli5mw7a6t1IGwOtfmcSAODq6Okz0zkXhS1zi9sz1ssjjIfslwPvcWw5TNhdjNBUg9fgfPLeqZlH6ng==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/leaflet": "^1.9.8", + "fflate": "^0.8.0" + } + }, "node_modules/svelte-parse-markup": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/svelte-parse-markup/-/svelte-parse-markup-0.1.5.tgz", @@ -7998,9 +9138,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "optional": true, - "peer": true + "optional": true }, "node_modules/tailwind-merge": { "version": "2.6.0", @@ -8112,6 +9250,34 @@ } } }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", + "optional": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/test-exclude": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", @@ -8170,9 +9336,9 @@ } }, "node_modules/three": { - "version": "0.169.0", - "resolved": "https://registry.npmjs.org/three/-/three-0.169.0.tgz", - "integrity": "sha512-Ed906MA3dR4TS5riErd4QBsRGPcx+HBDX2O5yYE5GqJeFQTPU+M56Va/f/Oph9X7uZo3W3o4l2ZhBZ6f6qUv0w==", + "version": "0.173.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.173.0.tgz", + "integrity": "sha512-AUwVmViIEUgBwxJJ7stnF0NkPpZxx1aZ6WiAbQ/Qq61h6I9UR4grXtZDmO8mnlaNORhHnIBlXJ1uBxILEKuVyw==", "license": "MIT" }, "node_modules/thumbhash": { @@ -8272,9 +9438,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", - "dev": true, "optional": true, - "peer": true, "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", @@ -8300,9 +9464,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.0.tgz", - "integrity": "sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz", + "integrity": "sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==", "dev": true, "license": "MIT", "engines": { @@ -8340,6 +9504,19 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "4.36.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.36.0.tgz", + "integrity": "sha512-3T/PUdKTCnkUmhQU6FFJEHsLwadsRegktX3TNHk+2JJB9HlA8gp1/VXblXVDI93kSnXF2rdPx0GMbHtJIV2LPg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.7.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", @@ -8379,6 +9556,19 @@ "node": ">=0.8.0" } }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/union-value": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", @@ -8397,9 +9587,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "dev": true, "optional": true, - "peer": true, "engines": { "node": ">= 4.0.0" } @@ -8448,9 +9636,7 @@ "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, "optional": true, - "peer": true, "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" @@ -8466,21 +9652,22 @@ "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", "dev": true, + "license": "Apache-2.0", "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, "node_modules/vite": { - "version": "6.0.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.11.tgz", - "integrity": "sha512-4VL9mQPKoHy4+FE0NnRE/kbY51TOfaknxAjt3fJbGJxhIpBZiqVzlZDEesWWsuREXHwNdAoOFZ9MkPEVXczHwg==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz", + "integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.24.2", - "postcss": "^8.4.49", - "rollup": "^4.23.0" + "esbuild": "^0.25.0", + "postcss": "^8.5.3", + "rollup": "^4.30.1" }, "bin": { "vite": "bin/vite.js" @@ -8557,16 +9744,16 @@ } }, "node_modules/vite-node": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.3.tgz", - "integrity": "sha512-0sQcwhwAEw/UJGojbhOrnq3HtiZ3tC7BzpAa0lx3QaTX0S3YX70iGcik25UBdB96pmdwjyY2uyKNYruxCDmiEg==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.7.tgz", + "integrity": "sha512-2fX0QwX4GkkkpULXdT1Pf4q0tC1i1lFOyseKoonavXUNlQ77KpW2XqBGGNIm/J4Ows4KxgGJzDguYVPKwG/n5A==", "dev": true, "license": "MIT", "dependencies": { "cac": "^6.7.14", "debug": "^4.4.0", "es-module-lexer": "^1.6.0", - "pathe": "^2.0.1", + "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0" }, "bin": { @@ -8617,31 +9804,31 @@ } }, "node_modules/vitest": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.3.tgz", - "integrity": "sha512-dWdwTFUW9rcnL0LyF2F+IfvNQWB0w9DERySCk8VMG75F8k25C7LsZoh6XfCjPvcR8Nb+Lqi9JKr6vnzH7HSrpQ==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.7.tgz", + "integrity": "sha512-IP7gPK3LS3Fvn44x30X1dM9vtawm0aesAa2yBIZ9vQf+qB69NXC5776+Qmcr7ohUXIQuLhk7xQR0aSUIDPqavg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "3.0.3", - "@vitest/mocker": "3.0.3", - "@vitest/pretty-format": "^3.0.3", - "@vitest/runner": "3.0.3", - "@vitest/snapshot": "3.0.3", - "@vitest/spy": "3.0.3", - "@vitest/utils": "3.0.3", - "chai": "^5.1.2", + "@vitest/expect": "3.0.7", + "@vitest/mocker": "3.0.7", + "@vitest/pretty-format": "^3.0.7", + "@vitest/runner": "3.0.7", + "@vitest/snapshot": "3.0.7", + "@vitest/spy": "3.0.7", + "@vitest/utils": "3.0.7", + "chai": "^5.2.0", "debug": "^4.4.0", "expect-type": "^1.1.0", "magic-string": "^0.30.17", - "pathe": "^2.0.1", + "pathe": "^2.0.3", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.0.3", + "vite-node": "3.0.7", "why-is-node-running": "^2.3.0" }, "bin": { @@ -8655,9 +9842,10 @@ }, "peerDependencies": { "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.0.3", - "@vitest/ui": "3.0.3", + "@vitest/browser": "3.0.7", + "@vitest/ui": "3.0.7", "happy-dom": "*", "jsdom": "*" }, @@ -8665,6 +9853,9 @@ "@edge-runtime/vm": { "optional": true }, + "@types/debug": { + "optional": true + }, "@types/node": { "optional": true }, @@ -8728,9 +9919,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, "optional": true, - "peer": true, "engines": { "node": ">=12" } @@ -8789,6 +9978,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -8805,6 +10000,16 @@ "node": ">=8" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", @@ -8907,6 +10112,13 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC", + "optional": true + }, "node_modules/ws": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", @@ -8943,14 +10155,12 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "optional": true, - "peer": true + "optional": true }, "node_modules/xmlhttprequest-ssl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", - "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", "engines": { "node": ">=0.4.0" } @@ -8964,6 +10174,13 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true + }, "node_modules/yaml": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", diff --git a/web/package.json b/web/package.json index 1402c0b868..1126c3652f 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.125.1", + "version": "1.129.0", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", @@ -38,6 +38,7 @@ "@types/justified-layout": "^4.1.4", "@types/lodash-es": "^4.17.12", "@types/luxon": "^3.4.2", + "@types/qrcode": "^1.5.5", "@typescript-eslint/eslint-plugin": "^8.20.0", "@typescript-eslint/parser": "^8.20.0", "@vitest/coverage-v8": "^3.0.0", @@ -45,10 +46,10 @@ "dotenv": "^16.4.7", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.0", - "eslint-plugin-svelte": "^2.46.1", - "eslint-plugin-unicorn": "^56.0.1", + "eslint-plugin-svelte": "^3.0.0", + "eslint-plugin-unicorn": "^57.0.0", "factory.ts": "^1.4.1", - "globals": "^15.14.0", + "globals": "^16.0.0", "postcss": "^8.5.0", "prettier": "^3.4.2", "prettier-plugin-organize-imports": "^4.0.0", @@ -67,20 +68,25 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.15.0", + "@immich/ui": "^0.17.0", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", "@photo-sphere-viewer/equirectangular-video-adapter": "^5.11.5", + "@photo-sphere-viewer/resolution-plugin": "^5.11.5", + "@photo-sphere-viewer/settings-plugin": "^5.11.5", "@photo-sphere-viewer/video-plugin": "^5.11.5", "@zoom-image/svelte": "^0.3.0", "dom-to-image": "^2.6.0", + "fabric": "^6.5.4", "handlebars": "^4.7.8", "intl-messageformat": "^10.7.11", "justified-layout": "^4.1.0", "lodash-es": "^4.17.21", "luxon": "^3.4.4", - "socket.io-client": "~4.7.5", + "pmtiles": "^4.3.0", + "qrcode": "^1.5.4", + "socket.io-client": "~4.8.0", "svelte-gestures": "^5.1.3", "svelte-i18n": "^4.0.1", "svelte-local-storage-store": "^0.6.4", @@ -88,6 +94,6 @@ "thumbhash": "^0.1.1" }, "volta": { - "node": "22.13.1" + "node": "22.14.0" } } diff --git a/web/src/app.css b/web/src/app.css index 7a547d3504..9bc1695a8f 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -15,7 +15,7 @@ /* dark */ --immich-dark-primary: 172 203 250; - --immich-dark-bg: 0 0 0; + --immich-dark-bg: 10 10 10; --immich-dark-fg: 229 231 235; --immich-dark-gray: 33 33 33; --immich-dark-error: 211 47 47; @@ -127,7 +127,7 @@ input:focus-visible { @layer utilities { .immich-form-input { - @apply rounded-xl bg-slate-200 px-3 py-3 text-sm focus:border-immich-primary disabled:cursor-not-allowed disabled:bg-gray-400 disabled:text-gray-200 dark:bg-gray-600 dark:text-immich-dark-fg dark:disabled:bg-gray-800; + @apply rounded-xl bg-slate-200 px-3 py-3 text-sm focus:border-immich-primary disabled:cursor-not-allowed disabled:bg-gray-400 disabled:text-gray-400 dark:bg-gray-600 dark:text-immich-dark-fg dark:disabled:bg-gray-800 dark:disabled:text-gray-200; } .immich-form-label { diff --git a/web/src/lib/actions/click-outside.ts b/web/src/lib/actions/click-outside.ts index 1a421f1f56..92775546aa 100644 --- a/web/src/lib/actions/click-outside.ts +++ b/web/src/lib/actions/click-outside.ts @@ -35,12 +35,12 @@ export function clickOutside(node: HTMLElement, options: Options = {}): ActionRe } }; - document.addEventListener('click', handleClick, true); + document.addEventListener('mousedown', handleClick, true); node.addEventListener('keydown', handleKey, false); return { destroy() { - document.removeEventListener('click', handleClick, true); + document.removeEventListener('mousedown', handleClick, true); node.removeEventListener('keydown', handleKey, false); }, }; diff --git a/web/src/lib/actions/intersection-observer.ts b/web/src/lib/actions/intersection-observer.ts index edbc07e5c1..3a10074051 100644 --- a/web/src/lib/actions/intersection-observer.ts +++ b/web/src/lib/actions/intersection-observer.ts @@ -60,7 +60,7 @@ const observe = (key: HTMLElement | string, target: HTMLElement, properties: Int (entries: IntersectionObserverEntry[]) => { // This IntersectionObserver is limited to observing a single element, the one the // action is attached to. If there are multiple entries, it means that this - // observer is being notified of multiple events that have occured quickly together, + // observer is being notified of multiple events that have occurred quickly together, // and the latest element is the one we are interested in. entries.sort((a, b) => a.time - b.time); diff --git a/web/src/lib/assets/svg-paths.ts b/web/src/lib/assets/svg-paths.ts index 9c37849fcc..ded3db0fc8 100644 --- a/web/src/lib/assets/svg-paths.ts +++ b/web/src/lib/assets/svg-paths.ts @@ -6,4 +6,5 @@ export const moonViewBox = '0 0 20 20'; export const sunViewBox = '0 0 20 20'; export const discordPath = - 'M 9.1367188 3.8691406 C 9.1217187 3.8691406 9.1067969 3.8700938 9.0917969 3.8710938 C 8.9647969 3.8810937 5.9534375 4.1403594 4.0234375 5.6933594 C 3.0154375 6.6253594 1 12.073203 1 16.783203 C 1 16.866203 1.0215 16.946531 1.0625 17.019531 C 2.4535 19.462531 6.2473281 20.102859 7.1113281 20.130859 L 7.1269531 20.130859 C 7.2799531 20.130859 7.4236719 20.057594 7.5136719 19.933594 L 8.3886719 18.732422 C 6.0296719 18.122422 4.8248594 17.086391 4.7558594 17.025391 C 4.5578594 16.850391 4.5378906 16.549563 4.7128906 16.351562 C 4.8068906 16.244563 4.9383125 16.189453 5.0703125 16.189453 C 5.1823125 16.189453 5.2957188 16.228594 5.3867188 16.308594 C 5.4157187 16.334594 7.6340469 18.216797 11.998047 18.216797 C 16.370047 18.216797 18.589328 16.325641 18.611328 16.306641 C 18.702328 16.227641 18.815734 16.189453 18.927734 16.189453 C 19.059734 16.189453 19.190156 16.243562 19.285156 16.351562 C 19.459156 16.549563 19.441141 16.851391 19.244141 17.025391 C 19.174141 17.087391 17.968375 18.120469 15.609375 18.730469 L 16.484375 19.933594 C 16.574375 20.057594 16.718094 20.130859 16.871094 20.130859 L 16.886719 20.130859 C 17.751719 20.103859 21.5465 19.463531 22.9375 17.019531 C 22.9785 16.947531 23 16.866203 23 16.783203 C 23 12.073203 20.984172 6.624875 19.951172 5.671875 C 18.047172 4.140875 15.036203 3.8820937 14.908203 3.8710938 C 14.895203 3.8700938 14.880188 3.8691406 14.867188 3.8691406 C 14.681188 3.8691406 14.510594 3.9793906 14.433594 4.1503906 C 14.427594 4.1623906 14.362062 4.3138281 14.289062 4.5488281 C 15.548063 4.7608281 17.094141 5.1895937 18.494141 6.0585938 C 18.718141 6.1975938 18.787437 6.4917969 18.648438 6.7167969 C 18.558438 6.8627969 18.402188 6.9433594 18.242188 6.9433594 C 18.156188 6.9433594 18.069234 6.9200937 17.990234 6.8710938 C 15.584234 5.3800938 12.578 5.3046875 12 5.3046875 C 11.422 5.3046875 8.4157187 5.3810469 6.0117188 6.8730469 C 5.9327188 6.9210469 5.8457656 6.9433594 5.7597656 6.9433594 C 5.5997656 6.9433594 5.4425625 6.86475 5.3515625 6.71875 C 5.2115625 6.49375 5.2818594 6.1985938 5.5058594 6.0585938 C 6.9058594 5.1905937 8.4528906 4.7627812 9.7128906 4.5507812 C 9.6388906 4.3147813 9.5714062 4.1643437 9.5664062 4.1523438 C 9.4894063 3.9813438 9.3217188 3.8691406 9.1367188 3.8691406 z M 12 7.3046875 C 12.296 7.3046875 14.950594 7.3403125 16.933594 8.5703125 C 17.326594 8.8143125 17.777234 8.9453125 18.240234 8.9453125 C 18.633234 8.9453125 19.010656 8.8555 19.347656 8.6875 C 19.964656 10.2405 20.690828 12.686219 20.923828 15.199219 C 20.883828 15.143219 20.840922 15.089109 20.794922 15.037109 C 20.324922 14.498109 19.644687 14.191406 18.929688 14.191406 C 18.332687 14.191406 17.754078 14.405437 17.330078 14.773438 C 17.257078 14.832437 15.505 16.21875 12 16.21875 C 8.496 16.21875 6.7450313 14.834687 6.7070312 14.804688 C 6.2540312 14.407687 5.6742656 14.189453 5.0722656 14.189453 C 4.3612656 14.189453 3.6838438 14.494391 3.2148438 15.025391 C 3.1658438 15.080391 3.1201719 15.138266 3.0761719 15.197266 C 3.3091719 12.686266 4.0344375 10.235594 4.6484375 8.6835938 C 4.9864375 8.8525938 5.3657656 8.9433594 5.7597656 8.9433594 C 6.2217656 8.9433594 6.6724531 8.8143125 7.0644531 8.5703125 C 9.0494531 7.3393125 11.704 7.3046875 12 7.3046875 z M 8.890625 10.044922 C 7.966625 10.044922 7.2167969 10.901031 7.2167969 11.957031 C 7.2167969 13.013031 7.965625 13.869141 8.890625 13.869141 C 9.815625 13.869141 10.564453 13.013031 10.564453 11.957031 C 10.564453 10.900031 9.815625 10.044922 8.890625 10.044922 z M 15.109375 10.044922 C 14.185375 10.044922 13.435547 10.901031 13.435547 11.957031 C 13.435547 13.013031 14.184375 13.869141 15.109375 13.869141 C 16.034375 13.869141 16.783203 13.013031 16.783203 11.957031 C 16.783203 10.900031 16.033375 10.044922 15.109375 10.044922 z'; + 'M81.15,0c-1.2376,2.1973-2.3489,4.4704-3.3591,6.794-9.5975-1.4396-19.3718-1.4396-28.9945,0-.985-2.3236-2.1216-4.5967-3.3591-6.794-9.0166,1.5407-17.8059,4.2431-26.1405,8.0568C2.779,32.5304-1.6914,56.3725.5312,79.8863c9.6732,7.1476,20.5083,12.603,32.0505,16.0884,2.6014-3.4854,4.8998-7.1981,6.8698-11.0623-3.738-1.3891-7.3497-3.1318-10.8098-5.1523.9092-.6567,1.7932-1.3386,2.6519-1.9953,20.281,9.547,43.7696,9.547,64.0758,0,.8587.7072,1.7427,1.3891,2.6519,1.9953-3.4601,2.0457-7.0718,3.7632-10.835,5.1776,1.97,3.8642,4.2683,7.5769,6.8698,11.0623,11.5419-3.4854,22.3769-8.9156,32.0509-16.0631,2.626-27.2771-4.496-50.9172-18.817-71.8548C98.9811,4.2684,90.1918,1.5659,81.1752.0505l-.0252-.0505ZM42.2802,65.4144c-6.2383,0-11.4159-5.6575-11.4159-12.6535s4.9755-12.6788,11.3907-12.6788,11.5169,5.708,11.4159,12.6788c-.101,6.9708-5.026,12.6535-11.3907,12.6535ZM84.3576,65.4144c-6.2637,0-11.3907-5.6575-11.3907-12.6535s4.9755-12.6788,11.3907-12.6788,11.4917,5.708,11.3906,12.6788c-.101,6.9708-5.026,12.6535-11.3906,12.6535Z'; +export const discordViewBox = '0 0 126.644 96'; diff --git a/web/src/lib/components/admin-page/jobs/job-tile.svelte b/web/src/lib/components/admin-page/jobs/job-tile.svelte index 0e39647c75..80dd29e0be 100644 --- a/web/src/lib/components/admin-page/jobs/job-tile.svelte +++ b/web/src/lib/components/admin-page/jobs/job-tile.svelte @@ -185,7 +185,7 @@ {#if !disabled && !multipleButtons && isIdle} onCommand({ command: JobCommand.Start, force: false })}> - {$t('start').toUpperCase()} + {missingText} {/if} diff --git a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte index 9b4f3ffdd6..2c59f59416 100644 --- a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte +++ b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte @@ -79,8 +79,7 @@ icon: mdiLibraryShelves, title: $getJobName(JobName.Library), subtitle: $t('admin.library_tasks_description'), - allText: $t('all'), - missingText: $t('refresh'), + missingText: $t('rescan'), }, [JobName.Sidecar]: { title: $getJobName(JobName.Sidecar), @@ -135,14 +134,14 @@ [JobName.StorageTemplateMigration]: { icon: mdiFolderMove, title: $getJobName(JobName.StorageTemplateMigration), - missingText: $t('missing'), + missingText: $t('start'), description: StorageMigrationDescription, }, [JobName.Migration]: { icon: mdiFolderMove, title: $getJobName(JobName.Migration), subtitle: $t('admin.migration_job_description'), - missingText: $t('missing'), + missingText: $t('start'), }, }; @@ -170,7 +169,7 @@
- {#each jobList as [jobName, { title, subtitle, description, disabled, allText, refreshText, missingText, icon, handleCommand: handleCommandOverride }]} + {#each jobList as [jobName, { title, subtitle, description, disabled, allText, refreshText, missingText, icon, handleCommand: handleCommandOverride }] (jobName)} {@const { jobCounts, queueStatus } = jobs[jobName]}
- {#each jobNames as jobName} + {#each jobNames as jobName (jobName)}
{#if isSystemConfigJobDto(jobName)}
- {#each config.machineLearning.urls as _, i} + {#each config.machineLearning.urls as _, i (i)} {#snippet removeButton()} {#if config.machineLearning.urls.length > 1} {#snippet children({ message })} - {message} + {message} {/snippet}

diff --git a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte index 74d240a4a6..9b4aa5e934 100644 --- a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte +++ b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte @@ -73,6 +73,7 @@ filetype: 'IMG', filetypefull: 'IMAGE', assetId: 'a8312960-e277-447d-b4ea-56717ccba856', + assetIdShort: '56717ccba856', album: $t('album_name'), }; @@ -203,7 +204,7 @@

UPLOAD_LOCATION/{$user.storageLabel || $user.id}UPLOAD_LOCATION/library/{$user.storageLabel || $user.id}/{parsedTemplate()}.jpg

@@ -224,7 +225,7 @@ bind:value={selectedPreset} onchange={handlePresetSelection} > - {#each templateOptions.presetOptions as preset} + {#each templateOptions.presetOptions as preset (preset)} {/each} @@ -245,7 +246,7 @@
diff --git a/web/src/lib/components/admin-page/settings/storage-template/supported-datetime-panel.svelte b/web/src/lib/components/admin-page/settings/storage-template/supported-datetime-panel.svelte index 379e366df6..9d8ff51cc0 100644 --- a/web/src/lib/components/admin-page/settings/storage-template/supported-datetime-panel.svelte +++ b/web/src/lib/components/admin-page/settings/storage-template/supported-datetime-panel.svelte @@ -19,6 +19,7 @@

{$t('date_and_time').toUpperCase()}

+

{$t('admin.storage_template_date_time_description')}

@@ -28,7 +29,7 @@

{$t('year').toUpperCase()}

    - {#each options.yearOptions as yearFormat} + {#each options.yearOptions as yearFormat, index (index)}
  • {'{{'}{yearFormat}{'}}'} - {getLuxonExample(yearFormat)}
  • {/each}
@@ -37,7 +38,7 @@

{$t('month').toUpperCase()}

    - {#each options.monthOptions as monthFormat} + {#each options.monthOptions as monthFormat, index (index)}
  • {'{{'}{monthFormat}{'}}'} - {getLuxonExample(monthFormat)}
  • {/each}
@@ -46,7 +47,7 @@

{$t('week').toUpperCase()}

    - {#each options.weekOptions as weekFormat} + {#each options.weekOptions as weekFormat, index (index)}
  • {'{{'}{weekFormat}{'}}'} - {getLuxonExample(weekFormat)}
  • {/each}
@@ -55,7 +56,7 @@

{$t('day').toUpperCase()}

    - {#each options.dayOptions as dayFormat} + {#each options.dayOptions as dayFormat, index (index)}
  • {'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}
  • {/each}
@@ -64,7 +65,7 @@

{$t('hour').toUpperCase()}

    - {#each options.hourOptions as dayFormat} + {#each options.hourOptions as dayFormat, index (index)}
  • {'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}
  • {/each}
@@ -73,7 +74,7 @@

{$t('minute').toUpperCase()}

    - {#each options.minuteOptions as dayFormat} + {#each options.minuteOptions as dayFormat, index (index)}
  • {'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}
  • {/each}
@@ -82,7 +83,7 @@

{$t('second').toUpperCase()}

    - {#each options.secondOptions as dayFormat} + {#each options.secondOptions as dayFormat, index (index)}
  • {'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}
  • {/each}
diff --git a/web/src/lib/components/admin-page/settings/storage-template/supported-variables-panel.svelte b/web/src/lib/components/admin-page/settings/storage-template/supported-variables-panel.svelte index 515f2e48f0..fc8f913281 100644 --- a/web/src/lib/components/admin-page/settings/storage-template/supported-variables-panel.svelte +++ b/web/src/lib/components/admin-page/settings/storage-template/supported-variables-panel.svelte @@ -27,6 +27,7 @@

{$t('other').toUpperCase()}

  • {`{{assetId}}`} - Asset ID
  • +
  • {`{{assetIdShort}}`} - Asset ID (last 12 characters)
  • {`{{album}}`} - Album Name
diff --git a/web/src/lib/components/admin-page/settings/template-settings/template-settings.svelte b/web/src/lib/components/admin-page/settings/template-settings/template-settings.svelte index c27df817c2..f23289d1e5 100644 --- a/web/src/lib/components/admin-page/settings/template-settings/template-settings.svelte +++ b/web/src/lib/components/admin-page/settings/template-settings/template-settings.svelte @@ -92,7 +92,7 @@ {/if} - {#each templateConfigs as { label, templateKey, descriptionTags, templateName }} + {#each templateConfigs as { label, templateKey, descriptionTags, templateName } (templateKey)} + import SharedLinkCopy from '$lib/components/sharedlinks-page/actions/shared-link-copy.svelte'; + import { locale } from '$lib/stores/preferences.store'; + import type { AlbumResponseDto, SharedLinkResponseDto } from '@immich/sdk'; + import { Text } from '@immich/ui'; + import { DateTime } from 'luxon'; + import { t } from 'svelte-i18n'; + + type Props = { + album: AlbumResponseDto; + sharedLink: SharedLinkResponseDto; + }; + + const { album, sharedLink }: Props = $props(); + + const getShareProperties = () => + [ + DateTime.fromISO(sharedLink.createdAt).toLocaleString( + { + month: 'long', + day: 'numeric', + year: 'numeric', + }, + { locale: $locale }, + ), + sharedLink.allowUpload && $t('upload'), + sharedLink.allowDownload && $t('download'), + sharedLink.showMetadata && $t('exif').toUpperCase(), + sharedLink.password && $t('password'), + ] + .filter(Boolean) + .join(' • '); + + +
+
+ {sharedLink.description || album.albumName} + {getShareProperties()} +
+ +
diff --git a/web/src/lib/components/album-page/album-summary.svelte b/web/src/lib/components/album-page/album-summary.svelte index f2cd23f616..3e6e160c9c 100644 --- a/web/src/lib/components/album-page/album-summary.svelte +++ b/web/src/lib/components/album-page/album-summary.svelte @@ -1,6 +1,5 @@ - {getDateRange(startDate, endDate)} + {getAlbumDateRange(album)} {$t('items_count', { values: { count: album.assetCount } })} diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 02544e3e07..8b5b2bff8b 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -4,7 +4,7 @@ import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store'; import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk'; - import { AssetStore } from '$lib/stores/assets.store'; + import { AssetStore } from '$lib/stores/assets-store.svelte'; import { cancelMultiselect, downloadAlbum } from '$lib/utils/asset-utils'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import DownloadAction from '../photos-page/actions/download-action.svelte'; diff --git a/web/src/lib/components/album-page/albums-controls.svelte b/web/src/lib/components/album-page/albums-controls.svelte index b518c56b66..1fff0c29a2 100644 --- a/web/src/lib/components/album-page/albums-controls.svelte +++ b/web/src/lib/components/album-page/albums-controls.svelte @@ -175,6 +175,7 @@ color="secondary" shape="round" icon={mdiUnfoldMoreHorizontal} + aria-label={$t('expand_all')} />
@@ -187,6 +188,7 @@ color="secondary" shape="round" icon={mdiUnfoldLessHorizontal} + aria-label={$t('collapse_all')} />
diff --git a/web/src/lib/components/album-page/albums-list.svelte b/web/src/lib/components/album-page/albums-list.svelte index 178190dc34..0696a937cc 100644 --- a/web/src/lib/components/album-page/albums-list.svelte +++ b/web/src/lib/components/album-page/albums-list.svelte @@ -35,6 +35,7 @@ locale, type AlbumViewSettings, } from '$lib/stores/preferences.store'; + import { userInteraction } from '$lib/stores/user.svelte'; import { goto } from '$app/navigation'; import { AppRoute } from '$lib/constants'; import { t } from 'svelte-i18n'; @@ -293,6 +294,15 @@ sharedAlbums[sharedAlbums.findIndex(({ id }) => id === album.id)] = album; }; + const updateRecentAlbumInfo = (album: AlbumResponseDto) => { + for (const cachedAlbum of userInteraction.recentAlbums || []) { + if (cachedAlbum.id === album.id) { + Object.assign(cachedAlbum, { ...cachedAlbum, ...album }); + break; + } + } + }; + const successEditAlbumInfo = (album: AlbumResponseDto) => { albumToEdit = null; @@ -308,6 +318,7 @@ }); updateAlbumInfo(album); + updateRecentAlbumInfo(album); }; const handleAddUsers = async (albumUsers: AlbumUserAddDto[]) => { diff --git a/web/src/lib/components/album-page/share-info-modal.svelte b/web/src/lib/components/album-page/share-info-modal.svelte index 778943af3a..35d8a84412 100644 --- a/web/src/lib/components/album-page/share-info-modal.svelte +++ b/web/src/lib/components/album-page/share-info-modal.svelte @@ -95,7 +95,7 @@

{$t('owner')}

- {#each album.albumUsers as { user, role }} + {#each album.albumUsers as { user, role } (user.id)}
diff --git a/web/src/lib/components/album-page/user-selection-modal.svelte b/web/src/lib/components/album-page/user-selection-modal.svelte index 85155866f9..cd454f515f 100644 --- a/web/src/lib/components/album-page/user-selection-modal.svelte +++ b/web/src/lib/components/album-page/user-selection-modal.svelte @@ -1,4 +1,5 @@ - + {#if Object.keys(selectedUsers).length > 0}
-

{$t('selected').toUpperCase()}

+

{$t('selected')}

- {#each Object.values(selectedUsers) as { user }} + {#each Object.values(selectedUsers) as { user } (user.id)} {#key user.id}
{#if users.length > 0 && users.length !== Object.keys(selectedUsers).length} -

{$t('suggestions').toUpperCase()}

+ {$t('users')}
- {#each users as user} + {#each users as user (user.id)} {#if !Object.keys(selectedUsers).includes(user.id)}
{/if} -
+
-
- + + {#if sharedLinks.length > 0} +
+ {$t('shared_links')} + {$t('view_all')} +
- {#if sharedLinks.length} - - -

{$t('view_links')}

-
+ + {#each sharedLinks as sharedLink (sharedLink.id)} + + {/each} + {/if} -
+ + + diff --git a/web/src/lib/components/asset-viewer/actions/action.ts b/web/src/lib/components/asset-viewer/actions/action.ts index f8cfd447f0..40b189080f 100644 --- a/web/src/lib/components/asset-viewer/actions/action.ts +++ b/web/src/lib/components/asset-viewer/actions/action.ts @@ -19,3 +19,4 @@ export type Action = { [K in AssetAction]: { type: K } & ActionMap[K]; }[AssetAction]; export type OnAction = (action: Action) => void; +export type PreAction = (action: Action) => void; diff --git a/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte b/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte index ab0da059d0..202f0e4593 100644 --- a/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte @@ -1,6 +1,7 @@ + (showSelectionModal = true) }} +/> + { try { + preAction({ type: AssetAction.TRASH, asset }); await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id] } }); onAction({ type: AssetAction.TRASH, asset }); diff --git a/web/src/lib/components/asset-viewer/album-list-item.svelte b/web/src/lib/components/asset-viewer/album-list-item.svelte index 43352a4904..1d7ee2971a 100644 --- a/web/src/lib/components/asset-viewer/album-list-item.svelte +++ b/web/src/lib/components/asset-viewer/album-list-item.svelte @@ -3,14 +3,25 @@ import { type AlbumResponseDto } from '@immich/sdk'; import { normalizeSearchString } from '$lib/utils/string-utils.js'; import AlbumListItemDetails from './album-list-item-details.svelte'; + import type { Action } from 'svelte/action'; + import { SCROLL_PROPERTIES } from '$lib/components/shared-components/album-selection/album-selection-utils'; interface Props { album: AlbumResponseDto; searchQuery?: string; + selected: boolean; onAlbumClick: () => void; } - let { album, searchQuery = '', onAlbumClick }: Props = $props(); + let { album, searchQuery = '', selected = false, onAlbumClick }: Props = $props(); + + const scrollIntoViewIfSelected: Action = (node) => { + $effect(() => { + if (selected) { + node.scrollIntoView(SCROLL_PROPERTIES); + } + }); + }; let albumNameArray: string[] = $state(['', '', '']); @@ -31,7 +42,10 @@ + {/each} +
+ {:else} +
+

No matching people found

+
+ {/if} +
+ + +
+
diff --git a/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte b/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte index 6da8cc33d3..7b9fd85b4a 100644 --- a/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte +++ b/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte @@ -24,7 +24,7 @@ {:then [data, { default: PhotoSphereViewer }]} {:catch} {$t('errors.failed_to_load_asset')} diff --git a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte index 0c8f76a01e..517e630dc9 100644 --- a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte +++ b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte @@ -7,18 +7,21 @@ type AdapterConstructor, type PluginConstructor, } from '@photo-sphere-viewer/core'; + import { SettingsPlugin } from '@photo-sphere-viewer/settings-plugin'; + import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin'; import '@photo-sphere-viewer/core/index.css'; + import '@photo-sphere-viewer/settings-plugin/index.css'; import { onDestroy, onMount } from 'svelte'; interface Props { panorama: string | { source: string }; - originalImageUrl?: string; + originalPanorama?: string | { source: string }; adapter?: AdapterConstructor | [AdapterConstructor, unknown]; plugins?: (PluginConstructor | [PluginConstructor, unknown])[]; navbar?: boolean; } - let { panorama, originalImageUrl, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props(); + let { panorama, originalPanorama, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props(); let container: HTMLDivElement | undefined = $state(); let viewer: Viewer; @@ -30,9 +33,33 @@ viewer = new Viewer({ adapter, - plugins, + plugins: [ + SettingsPlugin, + [ + ResolutionPlugin, + { + defaultResolution: $alwaysLoadOriginalFile && originalPanorama ? 'original' : 'default', + resolutions: [ + { + id: 'default', + label: 'Default', + panorama, + }, + ...(originalPanorama + ? [ + { + id: 'original', + label: 'Original', + panorama: originalPanorama, + }, + ] + : []), + ], + }, + ], + ...plugins, + ], container, - panorama, touchmoveTwoFingers: false, mousewheelCtrlKey: false, navbar, @@ -40,15 +67,14 @@ maxFov: 120, fisheye: false, }); + const resolutionPlugin = viewer.getPlugin(ResolutionPlugin) as ResolutionPlugin; - if (originalImageUrl && !$alwaysLoadOriginalFile) { + if (originalPanorama && !$alwaysLoadOriginalFile) { const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => { // zoomLevel range: [0, 100] if (Math.round(zoomLevel) >= 75) { // Replace the preview with the original - viewer.setPanorama(originalImageUrl, { showLoader: false, speed: 150 }).catch(() => { - viewer.setPanorama(panorama, { showLoader: false, speed: 0 }).catch(() => {}); - }); + void resolutionPlugin.setResolution('original'); viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler); } }; diff --git a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts index e1372e37da..d90fb89c23 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts +++ b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts @@ -7,6 +7,14 @@ import { sharedLinkFactory } from '@test-data/factories/shared-link-factory'; import { render } from '@testing-library/svelte'; import type { MockInstance } from 'vitest'; +class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +globalThis.ResizeObserver = ResizeObserver; + vi.mock('$lib/utils', async (originalImport) => { const meta = await originalImport(); return { @@ -40,7 +48,7 @@ describe('PhotoViewer component', () => { expect(getAssetThumbnailUrlSpy).toBeCalledWith({ id: asset.id, size: AssetMediaSize.Preview, - checksum: asset.checksum, + cacheKey: asset.thumbhash, }); expect(getAssetOriginalUrlSpy).not.toBeCalled(); }); @@ -50,7 +58,7 @@ describe('PhotoViewer component', () => { render(PhotoViewer, { asset }); expect(getAssetThumbnailUrlSpy).not.toBeCalled(); - expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, checksum: asset.checksum }); + expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash }); }); it('loads original for shared link when download permission is true and showMetadata permission is true', () => { @@ -59,7 +67,7 @@ describe('PhotoViewer component', () => { render(PhotoViewer, { asset, sharedLink }); expect(getAssetThumbnailUrlSpy).not.toBeCalled(); - expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, checksum: asset.checksum }); + expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash }); }); it('not loads original image when shared link download permission is false', () => { @@ -70,7 +78,7 @@ describe('PhotoViewer component', () => { expect(getAssetThumbnailUrlSpy).toBeCalledWith({ id: asset.id, size: AssetMediaSize.Preview, - checksum: asset.checksum, + cacheKey: asset.thumbhash, }); expect(getAssetOriginalUrlSpy).not.toBeCalled(); @@ -84,7 +92,7 @@ describe('PhotoViewer component', () => { expect(getAssetThumbnailUrlSpy).toBeCalledWith({ id: asset.id, size: AssetMediaSize.Preview, - checksum: asset.checksum, + cacheKey: asset.thumbhash, }); expect(getAssetOriginalUrlSpy).not.toBeCalled(); diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index bad8d3c404..70467ccb82 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -2,7 +2,6 @@ import { shortcuts } from '$lib/actions/shortcut'; import { zoomImageAction, zoomed } from '$lib/actions/zoom-image'; import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; - import { photoViewer } from '$lib/stores/assets.store'; import { boundingBoxesArray } from '$lib/stores/people.store'; import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store'; @@ -19,6 +18,9 @@ import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import { NotificationType, notificationController } from '../shared-components/notification/notification'; import { handleError } from '$lib/utils/handle-error'; + import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte'; + import { photoViewerImgElement } from '$lib/stores/assets-store.svelte'; + import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; interface Props { asset: AssetResponseDto; @@ -70,19 +72,19 @@ for (const preloadAsset of preloadAssets || []) { if (preloadAsset.type === AssetTypeEnum.Image) { let img = new Image(); - img.src = getAssetUrl(preloadAsset.id, useOriginal, preloadAsset.checksum); + img.src = getAssetUrl(preloadAsset.id, useOriginal, preloadAsset.thumbhash); } } }; - const getAssetUrl = (id: string, useOriginal: boolean, checksum: string) => { + const getAssetUrl = (id: string, useOriginal: boolean, cacheKey: string | null) => { if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) { - return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, checksum }); + return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey }); } return useOriginal - ? getAssetOriginalUrl({ id, checksum }) - : getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, checksum }); + ? getAssetOriginalUrl({ id, cacheKey }) + : getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey }); }; copyImage = async () => { @@ -91,7 +93,7 @@ } try { - await copyImageToClipboard($photoViewer ?? assetFileUrl); + await copyImageToClipboard($photoViewerImgElement ?? assetFileUrl); notificationController.show({ type: NotificationType.Info, message: $t('copied_image_to_clipboard'), @@ -106,6 +108,12 @@ $zoomed = $zoomed ? false : true; }; + $effect(() => { + if (isFaceEditMode.value && $photoZoomState.currentZoom > 1) { + zoomToggle(); + } + }); + const onCopyShortcut = (event: KeyboardEvent) => { if (globalThis.getSelection()?.type === 'Range') { return; @@ -158,7 +166,10 @@ preload(useOriginalImage, preloadAssets); }); - let imageLoaderUrl = $derived(getAssetUrl(asset.id, useOriginalImage, asset.checksum)); + let imageLoaderUrl = $derived(getAssetUrl(asset.id, useOriginalImage, asset.thumbhash)); + + let containerWidth = $state(0); + let containerHeight = $state(0); -
+
{/if} {$getAltText(asset)} - {#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewer) as boundingbox} + + {#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox}
{/each}
+ + {#if isFaceEditMode.value} + + {/if} {/if}
diff --git a/web/src/lib/components/asset-viewer/video-native-viewer.svelte b/web/src/lib/components/asset-viewer/video-native-viewer.svelte index 225068f791..46a0301306 100644 --- a/web/src/lib/components/asset-viewer/video-native-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-native-viewer.svelte @@ -9,11 +9,13 @@ import type { SwipeCustomEvent } from 'svelte-gestures'; import { fade } from 'svelte/transition'; import { t } from 'svelte-i18n'; + import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; + import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte'; interface Props { assetId: string; loopVideo: boolean; - checksum: string; + cacheKey: string | null; onPreviousAsset?: () => void; onNextAsset?: () => void; onVideoEnded?: () => void; @@ -24,7 +26,7 @@ let { assetId, loopVideo, - checksum, + cacheKey, onPreviousAsset = () => {}, onNextAsset = () => {}, onVideoEnded = () => {}, @@ -39,7 +41,7 @@ onMount(() => { if (videoPlayer) { - assetFileUrl = getAssetPlaybackUrl({ id: assetId, checksum }); + assetFileUrl = getAssetPlaybackUrl({ id: assetId, cacheKey }); forceMuted = false; videoPlayer.load(); } @@ -84,9 +86,23 @@ onPreviousAsset(); } }; + + let containerWidth = $state(0); + let containerHeight = $state(0); + + $effect(() => { + if (isFaceEditMode.value) { + videoPlayer?.pause(); + } + }); -
+
@@ -116,4 +132,8 @@
{/if} + + {#if isFaceEditMode.value} + + {/if}
diff --git a/web/src/lib/components/asset-viewer/video-panorama-viewer.svelte b/web/src/lib/components/asset-viewer/video-panorama-viewer.svelte index 73315d661e..a205ffce3c 100644 --- a/web/src/lib/components/asset-viewer/video-panorama-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-panorama-viewer.svelte @@ -1,5 +1,5 @@ - - {/snippet}
-
+

{$t('choose_matching_people_to_merge')}

diff --git a/web/src/lib/components/faces-page/merge-suggestion-modal.svelte b/web/src/lib/components/faces-page/merge-suggestion-modal.svelte index a4ac76f198..eaf9ab64e9 100644 --- a/web/src/lib/components/faces-page/merge-suggestion-modal.svelte +++ b/web/src/lib/components/faces-page/merge-suggestion-modal.svelte @@ -1,11 +1,12 @@ @@ -113,7 +119,9 @@
{#snippet stickyBottom()} - - + + {/snippet} diff --git a/web/src/lib/components/faces-page/people-card.svelte b/web/src/lib/components/faces-page/people-card.svelte index a83d1180f9..d12855c54f 100644 --- a/web/src/lib/components/faces-page/people-card.svelte +++ b/web/src/lib/components/faces-page/people-card.svelte @@ -1,30 +1,33 @@ @@ -50,16 +53,14 @@ altText={person.name} title={person.name} widthStyle="100%" + circle /> + {#if person.isFavorite} +
+ +
+ {/if}
- {#if person.name} - - {person.name} - - {/if} {#if showVerticalDots} @@ -73,9 +74,13 @@ title={$t('show_person_options')} > - +
{/if} diff --git a/web/src/lib/components/faces-page/people-infinite-scroll.svelte b/web/src/lib/components/faces-page/people-infinite-scroll.svelte index 0de084c4b2..173ca69cc1 100644 --- a/web/src/lib/components/faces-page/people-infinite-scroll.svelte +++ b/web/src/lib/components/faces-page/people-infinite-scroll.svelte @@ -27,7 +27,7 @@ }); -
+
{#each people as person, index (person.id)} {#if hasNextPage && index === people.length - 1}
diff --git a/web/src/lib/components/faces-page/person-side-panel.svelte b/web/src/lib/components/faces-page/person-side-panel.svelte index f2bab9996a..73c3ea7ae5 100644 --- a/web/src/lib/components/faces-page/person-side-panel.svelte +++ b/web/src/lib/components/faces-page/person-side-panel.svelte @@ -13,9 +13,10 @@ AssetTypeEnum, type AssetFaceResponseDto, type PersonResponseDto, + deleteFace, } from '@immich/sdk'; import Icon from '$lib/components/elements/icon.svelte'; - import { mdiAccountOff, mdiArrowLeftThin, mdiPencil, mdiRestart } from '@mdi/js'; + import { mdiAccountOff, mdiArrowLeftThin, mdiPencil, mdiRestart, mdiTrashCan } from '@mdi/js'; import { onMount } from 'svelte'; import { linear } from 'svelte/easing'; import { fly } from 'svelte/transition'; @@ -24,8 +25,10 @@ import AssignFaceSidePanel from './assign-face-side-panel.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import { zoomImageToBase64 } from '$lib/utils/people-utils'; - import { photoViewer } from '$lib/stores/assets.store'; + import { photoViewerImgElement } from '$lib/stores/assets-store.svelte'; import { t } from 'svelte-i18n'; + import { dialogController } from '$lib/components/shared-components/dialog/dialog'; + import { assetViewingStore } from '$lib/stores/asset-viewing.store'; interface Props { assetId: string; @@ -163,6 +166,30 @@ editedFace = face; showSelectedFaces = true; }; + + const deleteAssetFace = async (face: AssetFaceResponseDto) => { + try { + if (!face.person) { + return; + } + + const isConfirmed = await dialogController.show({ + prompt: $t('confirm_delete_face', { values: { name: face.person.name } }), + }); + + if (!isConfirmed) { + return; + } + + await deleteFace({ id: face.id, assetFaceDeleteDto: { force: false } }); + + peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id); + + await assetViewingStore.setAssetId(assetId); + } catch (error) { + handleError(error, $t('error_delete_face')); + } + };
{:else} - {#each peopleWithFaces as face, index} + {#each peopleWithFaces as face, index (face.id)} {@const personName = face.person ? face.person?.name : $t('face_unassigned')}
+ {#if face.person != null} +
+ deleteAssetFace(face)} + /> +
+ {/if}
{/each} diff --git a/web/src/lib/components/faces-page/unmerge-face-selector.svelte b/web/src/lib/components/faces-page/unmerge-face-selector.svelte index 06c53f3618..e808c98748 100644 --- a/web/src/lib/components/faces-page/unmerge-face-selector.svelte +++ b/web/src/lib/components/faces-page/unmerge-face-selector.svelte @@ -131,7 +131,7 @@
{/if} - {#if showChangeNameModal} - (showChangeNameModal = false)}> -
-
- - -
-
- - {#snippet stickyBottom()} - - - {/snippet} -
- {/if} - {#if showSetBirthDateModal} - import { afterNavigate, goto } from '$app/navigation'; + import { afterNavigate, goto, invalidateAll } from '$app/navigation'; import { page } from '$app/stores'; + import { clickOutside } from '$lib/actions/click-outside'; + import { listNavigation } from '$lib/actions/list-navigation'; import { scrollMemoryClearer } from '$lib/actions/scroll-memory'; import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte'; import EditNameInput from '$lib/components/faces-page/edit-name-input.svelte'; @@ -17,8 +19,10 @@ import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte'; import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; + import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; + import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; @@ -27,11 +31,12 @@ notificationController, } from '$lib/components/shared-components/notification/notification'; import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants'; + import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import { AssetStore } from '$lib/stores/assets.store'; + import { AssetStore } from '$lib/stores/assets-store.svelte'; + import { preferences } from '$lib/stores/user.store'; import { websocketEvents } from '$lib/stores/websocket'; import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils'; - import { clickOutside } from '$lib/actions/click-outside'; import { handleError } from '$lib/utils/handle-error'; import { isExternalUrl } from '$lib/utils/navigation'; import { @@ -50,16 +55,15 @@ mdiDotsVertical, mdiEyeOffOutline, mdiEyeOutline, + mdiHeartMinusOutline, + mdiHeartOutline, mdiPlus, } from '@mdi/js'; import { onDestroy, onMount } from 'svelte'; - import type { PageData } from './$types'; - import { listNavigation } from '$lib/actions/list-navigation'; import { t } from 'svelte-i18n'; - import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; - import { preferences } from '$lib/stores/user.store'; - import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; - import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; + import type { PageData } from './$types'; + import { locale } from '$lib/stores/preferences.store'; + import { DateTime } from 'luxon'; interface Props { data: PageData; @@ -75,12 +79,8 @@ $effect(() => { // Check to trigger rebuild the timeline when navigating between people from the info panel - const change = assetStoreOptions.personId !== data.person.id; assetStoreOptions.personId = data.person.id; handlePromiseError(assetStore.updateOptions(assetStoreOptions)); - if (change) { - assetStore.triggerUpdate(); - } }); const assetInteraction = new AssetInteraction(); @@ -92,6 +92,7 @@ let personMerge1: PersonResponseDto | undefined = $state(); let personMerge2: PersonResponseDto | undefined = $state(); let potentialMergePeople: PersonResponseDto[] = $state([]); + let isSuggestionSelectedByUser = $state(false); let personName = ''; let suggestedPeople: PersonResponseDto[] = $state([]); @@ -153,7 +154,7 @@ }); const handleUnmerge = () => { - $assetStore.removeAssets(assetInteraction.selectedAssetsArray.map((a) => a.id)); + assetStore.removeAssets(assetInteraction.selectedAssetsArray.map((a) => a.id)); assetInteraction.clearMultiselect(); viewMode = PersonPageViewMode.VIEW_ASSETS; }; @@ -180,6 +181,25 @@ } }; + const handleToggleFavorite = async () => { + try { + const updatedPerson = await updatePerson({ + id: person.id, + personUpdateDto: { isFavorite: !person.isFavorite }, + }); + + // Invalidate to reload the page data and have the favorite status updated + await invalidateAll(); + + notificationController.show({ + message: updatedPerson.isFavorite ? $t('added_to_favorites') : $t('removed_from_favorites'), + type: NotificationType.Info, + }); + } catch (error) { + handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: person.isFavorite } })); + } + }; + const handleMerge = async (person: PersonResponseDto) => { await updateAssetCount(); await handleGoBack(); @@ -233,15 +253,22 @@ personName = person.name; personMerge1 = person; personMerge2 = person2; + isSuggestionSelectedByUser = true; viewMode = PersonPageViewMode.SUGGEST_MERGE; }; const changeName = async () => { viewMode = PersonPageViewMode.VIEW_ASSETS; person.name = personName; - try { - isEditingName = false; + isEditingName = false; + if (isSuggestionSelectedByUser) { + // User canceled the merge + isSuggestionSelectedByUser = false; + return; + } + + try { person = await updatePerson({ id: person.id, personUpdateDto: { name: personName } }); notificationController.show({ @@ -329,7 +356,7 @@ }; const handleDeleteAssets = async (assetIds: string[]) => { - $assetStore.removeAssets(assetIds); + assetStore.removeAssets(assetIds); await updateAssetCount(); }; @@ -391,8 +418,8 @@ - assetStore.triggerUpdate()} /> - + + $assetStore.removeAssets(assetIds)} + onArchive={(assetIds) => assetStore.removeAssets(assetIds)} /> {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} @@ -437,6 +464,11 @@ icon={mdiAccountMultipleCheckOutline} onClick={() => (viewMode = PersonPageViewMode.MERGE_PEOPLE)} /> + {/snippet} @@ -509,12 +541,28 @@ heightStyle="3.375rem" />

{person.name || $t('add_a_name')}

-

+

{$t('assets_count', { values: { count: numberOfAssets } })}

+ {#if person.birthDate} +

+ {$t('person_birthdate', { + values: { + date: DateTime.fromISO(person.birthDate).toLocaleString( + { + month: 'numeric', + day: 'numeric', + year: 'numeric', + }, + { locale: $locale }, + ), + }, + })} +

+ {/if}
diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index b485dd532b..1b6ff3071a 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -1,4 +1,5 @@ {#if assetInteraction.selectionActive} @@ -82,7 +88,7 @@ - assetStore.triggerUpdate()} /> + {#if assetInteraction.selectedAssets.size > 1 || isAssetStackSelected} diff --git a/web/src/routes/(user)/places/+page.svelte b/web/src/routes/(user)/places/+page.svelte index 1808755482..636f28013f 100644 --- a/web/src/routes/(user)/places/+page.svelte +++ b/web/src/routes/(user)/places/+page.svelte @@ -1,13 +1,12 @@ - - {#if hasPlaces} -
- {#each places as item (item.id)} - {@const city = item.exifInfo.city} - -
- {city} -
- - {city} - -
- {/each} + + {#snippet buttons()} +
+
- {:else} -
-
- -

{$t('no_places')}

-
-
- {/if} + {/snippet} + +
diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index 97d0cacdce..c1b75bc561 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -29,9 +29,10 @@ type SmartSearchDto, type MetadataSearchDto, type AlbumResponseDto, + getTagById, } from '@immich/sdk'; import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js'; - import type { Viewport } from '$lib/stores/assets.store'; + import type { Viewport } from '$lib/stores/assets-store.svelte'; import { locale } from '$lib/stores/preferences.store'; import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import { handlePromiseError } from '$lib/utils'; @@ -136,7 +137,7 @@ await loadNextPage(); } - export const loadNextPage = async () => { + const loadNextPage = async () => { if (!nextPage || searchResultAssets.length >= MAX_ASSET_COUNT) { return; } @@ -194,7 +195,9 @@ model: $t('camera_model'), lensModel: $t('lens_model'), personIds: $t('people'), + tagIds: $t('tags'), originalFileName: $t('file_name'), + description: $t('description'), }; return keyMap[key] || key; } @@ -215,6 +218,19 @@ return personNames.join(', '); } + async function getTagNames(tagIds: string[]) { + const tagNames = await Promise.all( + tagIds.map(async (tagId) => { + const tag = await getTagById({ id: tagId }); + + return tag.value; + }), + ); + + return tagNames.join(', '); + } + + // eslint-disable-next-line no-self-assign const triggerAssetUpdate = () => (searchResultAssets = searchResultAssets); const onAddToAlbum = (assetIds: string[]) => { @@ -246,7 +262,7 @@ - + @@ -299,6 +315,10 @@ {#await getPersonName(value) then personName} {personName} {/await} + {:else if key === 'tagIds' && Array.isArray(value)} + {#await getTagNames(value) then tagNames} + {tagNames} + {/await} {:else if value === null || value === ''} {$t('unknown')} {:else} diff --git a/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte b/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte new file mode 100644 index 0000000000..6fd1f17ecc --- /dev/null +++ b/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte @@ -0,0 +1,119 @@ + + + + {#snippet buttons()} + + {/snippet} + +
+ {#if sharedLinks.length === 0} +
+

{$t('you_dont_have_any_shared_links')}

+
+ {:else} +
+ {#each filteredSharedLinks as link (link.id)} + handleDeleteLink(link.id)} /> + {/each} +
+ {/if} + + {#if sharedLink} + + {/if} +
+
diff --git a/web/src/routes/(user)/shared-links/[[id=id]]/+page.ts b/web/src/routes/(user)/shared-links/[[id=id]]/+page.ts new file mode 100644 index 0000000000..920e5bdba4 --- /dev/null +++ b/web/src/routes/(user)/shared-links/[[id=id]]/+page.ts @@ -0,0 +1,14 @@ +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import type { PageLoad } from './$types'; + +export const load = (async () => { + await authenticate(); + const $t = await getFormatter(); + + return { + meta: { + title: $t('shared_links'), + }, + }; +}) satisfies PageLoad; diff --git a/web/src/routes/(user)/sharing/sharedlinks/+page.svelte b/web/src/routes/(user)/sharing/sharedlinks/+page.svelte deleted file mode 100644 index b7d4da2941..0000000000 --- a/web/src/routes/(user)/sharing/sharedlinks/+page.svelte +++ /dev/null @@ -1,89 +0,0 @@ - - - goto(backUrl)}> - {#snippet leading()} - {$t('shared_links')} - {/snippet} - - -
-
-

{$t('manage_shared_links')}

-
- {#if sharedLinks.length === 0} -
-

{$t('you_dont_have_any_shared_links')}

-
- {:else} -
- {#each sharedLinks as link (link.id)} - handleDeleteLink(link.id)} onEdit={() => (editSharedLink = link)} /> - {/each} -
- {/if} -
- -{#if editSharedLink} - -{/if} diff --git a/web/src/routes/(user)/sharing/sharedlinks/+page.ts b/web/src/routes/(user)/sharing/sharedlinks/+page.ts index 920e5bdba4..59530fd83f 100644 --- a/web/src/routes/(user)/sharing/sharedlinks/+page.ts +++ b/web/src/routes/(user)/sharing/sharedlinks/+page.ts @@ -1,14 +1,7 @@ -import { authenticate } from '$lib/utils/auth'; -import { getFormatter } from '$lib/utils/i18n'; +import { AppRoute } from '$lib/constants'; +import { redirect } from '@sveltejs/kit'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate(); - const $t = await getFormatter(); - - return { - meta: { - title: $t('shared_links'), - }, - }; +export const load = (() => { + redirect(307, AppRoute.SHARED_LINKS); }) satisfies PageLoad; diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte index 4c0e90e97c..96bebe34c1 100644 --- a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -17,7 +17,7 @@ import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte'; import { AppRoute, AssetAction, QueryParameter, SettingInputFieldType } from '$lib/constants'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; - import { AssetStore } from '$lib/stores/assets.store'; + import { AssetStore } from '$lib/stores/assets-store.svelte'; import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils'; import { deleteTag, getAllTags, updateTag, upsertTags, type TagResponseDto } from '@immich/sdk'; import { Button, HStack, Text } from '@immich/ui'; diff --git a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte index 5246f3b797..e31929f2c5 100644 --- a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -15,7 +15,7 @@ } from '$lib/components/shared-components/notification/notification'; import { AppRoute } from '$lib/constants'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; - import { AssetStore } from '$lib/stores/assets.store'; + import { AssetStore } from '$lib/stores/assets-store.svelte'; import { featureFlags, serverConfig } from '$lib/stores/server-config.store'; import { handlePromiseError } from '$lib/utils'; import { handleError } from '$lib/utils/handle-error'; diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte index 4627d981b6..d517dad943 100644 --- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -190,6 +190,7 @@ icon={mdiKeyboard} title={$t('show_keyboard_shortcuts')} onclick={() => (isShowKeyboardShortcut = !isShowKeyboardShortcut)} + aria-label={$t('show_keyboard_shortcuts')} /> {/snippet} diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index b3ac52bd7c..aa89fc0480 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -9,7 +9,7 @@
- {$t('welcome_to_immich')} + {$t('welcome_to_immich')} diff --git a/web/src/routes/admin/jobs-status/+page.svelte b/web/src/routes/admin/jobs-status/+page.svelte index 6ab5cd33be..21381081e0 100644 --- a/web/src/routes/admin/jobs-status/+page.svelte +++ b/web/src/routes/admin/jobs-status/+page.svelte @@ -44,6 +44,9 @@ { title: $t('admin.person_cleanup_job'), value: ManualJobName.PersonCleanup }, { title: $t('admin.tag_cleanup_job'), value: ManualJobName.TagCleanup }, { title: $t('admin.user_cleanup_job'), value: ManualJobName.UserCleanup }, + { title: $t('admin.memory_cleanup_job'), value: ManualJobName.MemoryCleanup }, + { title: $t('admin.memory_generate_job'), value: ManualJobName.MemoryCreate }, + { title: $t('admin.backup_database'), value: ManualJobName.BackupDatabase }, ].map(({ value, title }) => ({ id: value, label: title, value })); const handleCancel = () => (isOpen = false); diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte index 34a42446cd..3e3a5491fa 100644 --- a/web/src/routes/admin/library-management/+page.svelte +++ b/web/src/routes/admin/library-management/+page.svelte @@ -1,5 +1,4 @@